@revenexx/studio-integrations 0.3.0 → 0.4.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/README.md +5 -4
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/EventTypeCombobox.vue +2 -1
- package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +7 -1
- package/dist/runtime/components/Integrations/Sidebar.vue +2 -1
- package/dist/runtime/pages/integrations/templates/[slug].d.vue.ts +2 -0
- package/dist/runtime/pages/integrations/templates/[slug].vue +536 -0
- package/dist/runtime/pages/integrations/templates/[slug].vue.d.ts +2 -0
- package/dist/runtime/pages/integrations/templates/index.d.vue.ts +2 -0
- package/dist/runtime/pages/integrations/templates/index.vue +220 -0
- package/dist/runtime/pages/integrations/templates/index.vue.d.ts +2 -0
- package/dist/runtime/pages/integrations/workflows/[id].vue +4 -2
- package/package.json +7 -4
package/README.md
CHANGED
|
@@ -19,8 +19,9 @@ pnpm add @revenexx/studio-integrations @revenexx/studio-shared @revenexx/studio
|
|
|
19
19
|
```ts
|
|
20
20
|
// nuxt.config.ts
|
|
21
21
|
export default defineNuxtConfig({
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// studio-shared is a Nuxt MODULE since 0.2.0 (shell primitives by their
|
|
23
|
+
// bare names + the platform provider) — do NOT `extends` it anymore.
|
|
24
|
+
modules: ['@solar-icons/nuxt', '@revenexx/studio-shared', '@revenexx/studio-integrations'],
|
|
24
25
|
})
|
|
25
26
|
```
|
|
26
27
|
|
|
@@ -39,14 +40,14 @@ component styling silently breaks:
|
|
|
39
40
|
@import "tailwindcss";
|
|
40
41
|
@import "@revenexx/studio/tokens";
|
|
41
42
|
@import "@revenexx/studio/fonts";
|
|
42
|
-
@source "../node_modules/@revenexx/studio-shared/
|
|
43
|
+
@source "../node_modules/@revenexx/studio-shared/dist/runtime";
|
|
43
44
|
@source "../node_modules/@revenexx/studio-integrations/dist/runtime";
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
Adjust the relative paths to reach `node_modules` from your CSS file.
|
|
47
48
|
|
|
48
49
|
## Peers
|
|
49
|
-
`@revenexx/studio`, `@revenexx/studio-shared
|
|
50
|
+
`@revenexx/studio`, `@revenexx/studio-shared` ^0.2 (module form), `nuxt` ^4, `vue` ^3.5,
|
|
50
51
|
`@vue-flow/*` + `@dagrejs/dagre` (workflow editor), `lucide-vue-next`; register
|
|
51
52
|
`@solar-icons/nuxt` in the host.
|
|
52
53
|
|
package/dist/module.json
CHANGED
|
@@ -5,7 +5,8 @@ const props = defineProps({
|
|
|
5
5
|
modelValue: { type: String, required: true }
|
|
6
6
|
});
|
|
7
7
|
const emit = defineEmits(["update:modelValue"]);
|
|
8
|
-
const catalog = useDefinedEvents()
|
|
8
|
+
const catalog = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), listens: ref([]), loading: ref(false), load: async () => {
|
|
9
|
+
} };
|
|
9
10
|
onMounted(() => {
|
|
10
11
|
void catalog.load();
|
|
11
12
|
});
|
|
@@ -5,7 +5,13 @@ import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogHeader,
|
|
|
5
5
|
import { useIntegrationsCredentials } from "../../composables/useIntegrationsCredentials";
|
|
6
6
|
const open = defineModel("open", { type: Boolean, ...{ default: false } });
|
|
7
7
|
const emit = defineEmits(["created"]);
|
|
8
|
-
const sftp = useConsoleSftp()
|
|
8
|
+
const sftp = typeof useConsoleSftp === "function" ? useConsoleSftp() : {
|
|
9
|
+
accounts: ref([]),
|
|
10
|
+
loading: ref(false),
|
|
11
|
+
activeTenantSlug: ref(null),
|
|
12
|
+
load: async () => null,
|
|
13
|
+
revealPassword: async () => null
|
|
14
|
+
};
|
|
9
15
|
const { create } = useIntegrationsCredentials();
|
|
10
16
|
const busyId = ref(null);
|
|
11
17
|
const error = ref(null);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { Pulse as Activity, ClockCircle as Clock, Home, Key as KeyRound, ShieldCheck, ShieldKeyhole as Credentials, Programming as Workflow } from "#solar-icons";
|
|
2
|
+
import { Pulse as Activity, ClockCircle as Clock, Home, Key as KeyRound, Layers, ShieldCheck, ShieldKeyhole as Credentials, Programming as Workflow } from "#solar-icons";
|
|
3
3
|
import SidebarNav from "@revenexx/studio-shared/components/Shared/SidebarNav.vue";
|
|
4
4
|
const { workflows } = useIntegrationsWorkflows();
|
|
5
5
|
const { runningCount } = useIntegrationsRuns();
|
|
@@ -18,6 +18,7 @@ const sections = computed(() => [
|
|
|
18
18
|
label: "Integrations",
|
|
19
19
|
items: [
|
|
20
20
|
{ label: "Workflows", to: "/integrations/workflows", icon: Workflow, badge: workflows.value.length || null },
|
|
21
|
+
{ label: "Templates", to: "/integrations/templates", icon: Layers },
|
|
21
22
|
{ label: "Runs", to: "/integrations/runs", icon: Activity, badge: runningCount.value || null },
|
|
22
23
|
{ label: "Schedules", to: "/integrations/schedules", icon: Clock, badge: schedulesActive.value || null },
|
|
23
24
|
{ label: "Credentials", to: "/integrations/credentials", icon: Credentials, badge: credentialsTotal.value || null },
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, reactive, ref } from "vue";
|
|
3
|
+
import { marked } from "marked";
|
|
4
|
+
import { AltArrowRight as Chevron, Bolt as Zap, CheckCircle as CheckCircle2, ClockCircle as Clock, Global as Webhook, Key, Play, RefreshCircle as Loader2, Shield as ShieldAlert, User } from "#solar-icons";
|
|
5
|
+
import {
|
|
6
|
+
Badge,
|
|
7
|
+
Button,
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
Input,
|
|
15
|
+
Label,
|
|
16
|
+
Select,
|
|
17
|
+
SelectContent,
|
|
18
|
+
SelectItem,
|
|
19
|
+
SelectTrigger,
|
|
20
|
+
SelectValue
|
|
21
|
+
} from "@revenexx/studio";
|
|
22
|
+
import TemplateFlowPreview from "../../../components/Integrations/TemplateFlowPreview.vue";
|
|
23
|
+
import NodeLogo from "../../../components/Integrations/NodeLogo.vue";
|
|
24
|
+
import { TEMPLATE_CATEGORIES } from "../../../data/integrationTemplates";
|
|
25
|
+
const SELECT_LATER = "__later__";
|
|
26
|
+
const route = useRoute();
|
|
27
|
+
const router = useRouter();
|
|
28
|
+
const { get, load, loaded, fetchDetail, requirements, instantiate } = useIntegrationsTemplates();
|
|
29
|
+
const { load: loadNodeCatalog, findManifest, resolveLocalized } = useIntegrationsNodes();
|
|
30
|
+
const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
|
|
31
|
+
const { ofType, load: loadCredentials } = useIntegrationsCredentials();
|
|
32
|
+
const slug = computed(() => String(route.params.slug));
|
|
33
|
+
const tpl = computed(() => get(slug.value));
|
|
34
|
+
useHead({ title: () => `${tpl.value?.name ?? "Template"} \xB7 Integration Studio` });
|
|
35
|
+
const reqs = ref(null);
|
|
36
|
+
onMounted(async () => {
|
|
37
|
+
await load();
|
|
38
|
+
await Promise.all([fetchDetail(slug.value), loadNodeCatalog(), loadCredentials(), loadSecrets()]);
|
|
39
|
+
reqs.value = await requirements(slug.value);
|
|
40
|
+
});
|
|
41
|
+
const descriptionHtml = computed(() => {
|
|
42
|
+
const md = tpl.value?.descriptionMd ?? "";
|
|
43
|
+
if (!md.trim()) return "";
|
|
44
|
+
const withoutH1 = md.replace(/^#\s[^\n]*\n+/, "");
|
|
45
|
+
return marked.parse(withoutH1);
|
|
46
|
+
});
|
|
47
|
+
const steps = computed(() => {
|
|
48
|
+
const blob = tpl.value?.blob;
|
|
49
|
+
if (!blob?.nodes.length) return [];
|
|
50
|
+
const nodeById = new Map(blob.nodes.map((n) => [n.id, n]));
|
|
51
|
+
const happyEdges = (blob.edges ?? []).filter((e) => e.from.output !== "error");
|
|
52
|
+
const triggerHandles = new Set((blob.triggers ?? []).map((t) => t.id));
|
|
53
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
54
|
+
for (const e of happyEdges) {
|
|
55
|
+
if (!triggerHandles.has(e.from.nodeId)) incoming.set(e.to.nodeId, (incoming.get(e.to.nodeId) ?? 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
const ordered = [];
|
|
58
|
+
const seen = /* @__PURE__ */ new Set();
|
|
59
|
+
const queue = happyEdges.filter((e) => triggerHandles.has(e.from.nodeId)).map((e) => e.to.nodeId);
|
|
60
|
+
while (queue.length) {
|
|
61
|
+
const id = queue.shift();
|
|
62
|
+
if (seen.has(id)) continue;
|
|
63
|
+
seen.add(id);
|
|
64
|
+
const node = nodeById.get(id);
|
|
65
|
+
if (node) ordered.push(node);
|
|
66
|
+
for (const e of happyEdges) {
|
|
67
|
+
if (e.from.nodeId === id) queue.push(e.to.nodeId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const n of blob.nodes) {
|
|
71
|
+
if (!seen.has(n.id) && n.nodeSlug !== "revenexx:stop-and-error") ordered.push(n);
|
|
72
|
+
}
|
|
73
|
+
return ordered.filter((n) => n.nodeSlug !== "revenexx:stop-and-error").map((node) => {
|
|
74
|
+
const manifest = findManifest(node.nodeSlug, node.nodeVersion);
|
|
75
|
+
return {
|
|
76
|
+
node,
|
|
77
|
+
manifest,
|
|
78
|
+
label: manifest ? resolveLocalized(manifest.name) : prettify(node.nodeSlug),
|
|
79
|
+
blurb: manifest ? firstSentence(resolveLocalized(manifest.description)) : ""
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
function prettify(s) {
|
|
84
|
+
const tail = s.includes(":") ? s.split(":").pop() : s;
|
|
85
|
+
return tail.split("-").map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
|
86
|
+
}
|
|
87
|
+
function firstSentence(text) {
|
|
88
|
+
const idx = text.indexOf(". ");
|
|
89
|
+
return idx === -1 ? text : text.slice(0, idx + 1);
|
|
90
|
+
}
|
|
91
|
+
const TRIGGER_ICON = { schedule: Clock, webhook: Webhook, manual: Play, event: Zap };
|
|
92
|
+
const triggerChips = computed(() => (tpl.value?.blob.triggers ?? []).map((t) => {
|
|
93
|
+
let label = t.type.charAt(0).toUpperCase() + t.type.slice(1);
|
|
94
|
+
if (t.type === "schedule" && t.config?.cron) label = `Schedule \xB7 ${t.config.cron}${t.config.timezone ? ` (${t.config.timezone})` : ""}`;
|
|
95
|
+
if (t.type === "webhook" && t.config?.path) label = `Webhook \xB7 ${t.config.path}`;
|
|
96
|
+
if (t.type === "manual") label = "Run on demand";
|
|
97
|
+
return { key: t.id, icon: TRIGGER_ICON[t.type] ?? Zap, label };
|
|
98
|
+
}));
|
|
99
|
+
const variables = computed(() => Object.entries(tpl.value?.blob.variables ?? {}).map(([key, v]) => ({
|
|
100
|
+
key,
|
|
101
|
+
default: String(v?.default ?? ""),
|
|
102
|
+
description: resolveLocalized(v?.description ?? "")
|
|
103
|
+
})));
|
|
104
|
+
const variableColumns = [
|
|
105
|
+
{ key: "key", label: "Variable", width: "minmax(0,180px)", sortable: true },
|
|
106
|
+
{ key: "description", label: "Description" },
|
|
107
|
+
{ key: "default", label: "Default", width: "minmax(0,200px)" }
|
|
108
|
+
];
|
|
109
|
+
const credentialReqs = computed(() => reqs.value?.credentialTypes ?? []);
|
|
110
|
+
const secretReqs = computed(() => reqs.value?.secrets ?? []);
|
|
111
|
+
const secretChecks = computed(() => {
|
|
112
|
+
const fromApi = secretReqs.value;
|
|
113
|
+
if (fromApi.length) {
|
|
114
|
+
return fromApi.map((s) => ({
|
|
115
|
+
key: s.key,
|
|
116
|
+
label: s.key ?? "(chosen at install)",
|
|
117
|
+
present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
return (tpl.value?.requiredSecrets ?? []).map((key) => ({
|
|
121
|
+
key,
|
|
122
|
+
label: key,
|
|
123
|
+
present: secretKeys.value.includes(key)
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
const missingSecrets = computed(() => secretChecks.value.filter((s) => !s.present).length);
|
|
127
|
+
const missingCredentials = computed(() => credentialReqs.value.filter((c) => !ofType(c.slug).length).length);
|
|
128
|
+
const setupReady = computed(() => missingSecrets.value === 0 && missingCredentials.value === 0);
|
|
129
|
+
const packageLabel = computed(() => {
|
|
130
|
+
const pkg = tpl.value?.package;
|
|
131
|
+
if (!pkg) return null;
|
|
132
|
+
const tail = (pkg.name.split("/").pop() ?? pkg.name).replace(/^integrations-nodes-/, "");
|
|
133
|
+
if (tail === "core") return null;
|
|
134
|
+
return pkg.label ?? tail.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
|
|
135
|
+
});
|
|
136
|
+
const categoryLabel = computed(() => {
|
|
137
|
+
const cat = tpl.value?.category;
|
|
138
|
+
return TEMPLATE_CATEGORIES.find((c) => c.key === cat)?.label ?? (cat ? cat.charAt(0).toUpperCase() + cat.slice(1) : "");
|
|
139
|
+
});
|
|
140
|
+
const installOpen = ref(false);
|
|
141
|
+
const installing = ref(false);
|
|
142
|
+
const installError = ref(null);
|
|
143
|
+
const name = ref("");
|
|
144
|
+
const bindings = reactive({});
|
|
145
|
+
function nodesFor(usedBy) {
|
|
146
|
+
return [...new Set(usedBy.map((u) => u.nodeId))];
|
|
147
|
+
}
|
|
148
|
+
function openInstall() {
|
|
149
|
+
if (!tpl.value) return;
|
|
150
|
+
name.value = tpl.value.name;
|
|
151
|
+
for (const k of Object.keys(bindings)) delete bindings[k];
|
|
152
|
+
installError.value = null;
|
|
153
|
+
installOpen.value = true;
|
|
154
|
+
}
|
|
155
|
+
async function confirmInstall() {
|
|
156
|
+
if (!tpl.value) return;
|
|
157
|
+
installing.value = true;
|
|
158
|
+
installError.value = null;
|
|
159
|
+
try {
|
|
160
|
+
const credentialBindings = {};
|
|
161
|
+
for (const [nodeId, credId] of Object.entries(bindings)) {
|
|
162
|
+
if (credId && credId !== SELECT_LATER) credentialBindings[nodeId] = credId;
|
|
163
|
+
}
|
|
164
|
+
const id = await instantiate(tpl.value.slug, {
|
|
165
|
+
name: name.value.trim() || void 0,
|
|
166
|
+
credentialBindings
|
|
167
|
+
});
|
|
168
|
+
if (id) await router.push(`/integrations/workflows/${id}`);
|
|
169
|
+
} catch (e) {
|
|
170
|
+
installError.value = e.message || "Install failed.";
|
|
171
|
+
} finally {
|
|
172
|
+
installing.value = false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const PUBLISHER_KIND_LABEL = { revenexx: "Official", partner: "Partner", community: "Community" };
|
|
176
|
+
function publisherBadgeClass(kind) {
|
|
177
|
+
return kind === "revenexx" ? "bg-violet-500/10 text-violet-700 border-violet-500/20" : kind === "partner" ? "bg-amber-500/10 text-amber-700 border-amber-500/20" : "bg-slate-500/10 text-slate-700 border-slate-500/20";
|
|
178
|
+
}
|
|
179
|
+
function levelDot(level) {
|
|
180
|
+
return level === "beginner" ? "bg-emerald-500" : level === "intermediate" ? "bg-amber-500" : "bg-rose-500";
|
|
181
|
+
}
|
|
182
|
+
</script>
|
|
183
|
+
|
|
184
|
+
<template>
|
|
185
|
+
<IntegrationsLayout>
|
|
186
|
+
<template v-if="tpl">
|
|
187
|
+
<PageHeader :title="tpl.name">
|
|
188
|
+
<template #title>
|
|
189
|
+
<span class="inline-flex items-center gap-1.5">
|
|
190
|
+
<NuxtLink to="/integrations/templates" class="font-normal text-muted-foreground/80 hover:text-primary transition-colors">Templates</NuxtLink>
|
|
191
|
+
<Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
|
|
192
|
+
<span class="truncate">{{ tpl.name }}</span>
|
|
193
|
+
</span>
|
|
194
|
+
</template>
|
|
195
|
+
<template #actions>
|
|
196
|
+
<Button class="h-7 text-sm" @click="openInstall">
|
|
197
|
+
Use template
|
|
198
|
+
</Button>
|
|
199
|
+
</template>
|
|
200
|
+
</PageHeader>
|
|
201
|
+
|
|
202
|
+
<div class="p-4 space-y-4">
|
|
203
|
+
<!-- Hero: the live workflow preview on the editor's dotted canvas -->
|
|
204
|
+
<div class="relative overflow-hidden rounded-lg border border-black/[0.06] bg-slate-50/70 [background-image:radial-gradient(circle,rgba(15,23,42,0.07)_1px,transparent_1px)] [background-size:14px_14px]">
|
|
205
|
+
<TemplateFlowPreview v-if="tpl.detailLoaded" :blob="tpl.blob" :height="400" class="px-4" />
|
|
206
|
+
<div v-else class="flex h-[400px] items-center justify-center">
|
|
207
|
+
<Loader2 :size="18" class="animate-spin text-muted-foreground/50" />
|
|
208
|
+
</div>
|
|
209
|
+
<div class="absolute left-3 top-3 flex items-center gap-1.5">
|
|
210
|
+
<span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
|
|
211
|
+
{{ categoryLabel }}
|
|
212
|
+
</span>
|
|
213
|
+
<span class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
|
|
214
|
+
<span class="size-1.5 rounded-full" :class="levelDot(tpl.level)" />
|
|
215
|
+
{{ tpl.level }}
|
|
216
|
+
</span>
|
|
217
|
+
<span v-if="packageLabel" class="inline-flex items-center whitespace-nowrap rounded-full bg-violet-100 px-2 py-0.5 uppercase text-xs font-medium text-violet-700">
|
|
218
|
+
{{ packageLabel }}
|
|
219
|
+
</span>
|
|
220
|
+
</div>
|
|
221
|
+
<div class="absolute right-3 top-3 flex items-center gap-1.5">
|
|
222
|
+
<span
|
|
223
|
+
v-for="chip in triggerChips"
|
|
224
|
+
:key="chip.key"
|
|
225
|
+
class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700"
|
|
226
|
+
>
|
|
227
|
+
<component :is="chip.icon" :size="10" />
|
|
228
|
+
{{ chip.label }}
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div class="grid gap-4 lg:grid-cols-3">
|
|
234
|
+
<div class="lg:col-span-2 space-y-4">
|
|
235
|
+
<!-- About: the registry description, rendered -->
|
|
236
|
+
<div v-if="descriptionHtml" class="rounded-lg border border-black/[0.06] bg-white p-4">
|
|
237
|
+
<h3 class="text-sm font-semibold mb-2">About this template</h3>
|
|
238
|
+
<div
|
|
239
|
+
class="text-sm leading-relaxed text-foreground/90 space-y-2 [&_h1]:text-sm [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:text-sm [&_h3]:font-semibold [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5 [&_li]:mt-1 [&_code]:rounded [&_code]:bg-slate-100 [&_code]:px-1 [&_code]:py-px [&_code]:font-mono [&_code]:text-[13px] [&_a]:text-primary [&_a]:underline [&_strong]:font-semibold"
|
|
240
|
+
v-html="descriptionHtml"
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<!-- Step by step, humanized from the actual nodes -->
|
|
245
|
+
<div class="rounded-lg border border-black/[0.06] bg-white p-4">
|
|
246
|
+
<h3 class="text-sm font-semibold mb-3">Step by step</h3>
|
|
247
|
+
<ol v-if="steps.length" class="space-y-2.5">
|
|
248
|
+
<li
|
|
249
|
+
v-for="(step, i) in steps"
|
|
250
|
+
:key="step.node.id"
|
|
251
|
+
class="flex items-start gap-3"
|
|
252
|
+
>
|
|
253
|
+
<span class="flex size-6 shrink-0 items-center justify-center rounded-md bg-slate-100 text-sm font-semibold tabular-nums text-slate-500">
|
|
254
|
+
{{ i + 1 }}
|
|
255
|
+
</span>
|
|
256
|
+
<span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-white ring-1 ring-black/[0.08]">
|
|
257
|
+
<NodeLogo :manifest="step.manifest" :size="15" />
|
|
258
|
+
</span>
|
|
259
|
+
<span class="min-w-0 pt-0.5">
|
|
260
|
+
<span class="block text-sm font-medium text-foreground">{{ step.label }}</span>
|
|
261
|
+
<span v-if="step.blurb" class="block text-sm text-muted-foreground">{{ step.blurb }}</span>
|
|
262
|
+
</span>
|
|
263
|
+
</li>
|
|
264
|
+
</ol>
|
|
265
|
+
<p v-else class="text-sm text-muted-foreground italic">
|
|
266
|
+
No step-by-step outline for this template yet.
|
|
267
|
+
</p>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<!-- Variables the operator can tune after install -->
|
|
271
|
+
<div v-if="variables.length" class="rounded-lg border border-black/[0.06] bg-white p-4">
|
|
272
|
+
<h3 class="text-sm font-semibold mb-1">Variables you can tune</h3>
|
|
273
|
+
<p class="text-sm text-muted-foreground mb-3">Set once after install, used across the workflow.</p>
|
|
274
|
+
<DataTable
|
|
275
|
+
:data="variables"
|
|
276
|
+
:columns="variableColumns"
|
|
277
|
+
:row-key="(v2) => v2.key"
|
|
278
|
+
:default-sort="{ column: 'key', direction: 'asc' }"
|
|
279
|
+
:search="false"
|
|
280
|
+
:columns-menu="false"
|
|
281
|
+
:pagination="false"
|
|
282
|
+
>
|
|
283
|
+
<template #cell-key="{ row: v }">
|
|
284
|
+
<code class="font-mono text-[13px]">{{ v.key }}</code>
|
|
285
|
+
</template>
|
|
286
|
+
<template #cell-description="{ row: v }">
|
|
287
|
+
<span class="text-sm text-muted-foreground">{{ v.description }}</span>
|
|
288
|
+
</template>
|
|
289
|
+
<template #cell-default="{ row: v }">
|
|
290
|
+
<code v-if="v.default" class="rounded bg-slate-100 px-1.5 py-0.5 font-mono text-xs text-slate-600">{{ v.default }}</code>
|
|
291
|
+
</template>
|
|
292
|
+
</DataTable>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<!-- Setup rail -->
|
|
297
|
+
<div class="space-y-3">
|
|
298
|
+
<!-- Readiness + CTA -->
|
|
299
|
+
<div class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2.5">
|
|
300
|
+
<div v-if="setupReady" class="flex items-center gap-1.5 text-sm text-emerald-700">
|
|
301
|
+
<CheckCircle2 :size="14" />
|
|
302
|
+
Everything is ready to install.
|
|
303
|
+
</div>
|
|
304
|
+
<div v-else class="text-sm text-amber-700 space-y-0.5">
|
|
305
|
+
<div v-if="missingCredentials" class="flex items-center gap-1.5">
|
|
306
|
+
<Key :size="12" />
|
|
307
|
+
{{ missingCredentials }} credential{{ missingCredentials === 1 ? "" : "s" }} to connect
|
|
308
|
+
</div>
|
|
309
|
+
<div v-if="missingSecrets" class="flex items-center gap-1.5">
|
|
310
|
+
<ShieldAlert :size="12" />
|
|
311
|
+
{{ missingSecrets }} secret{{ missingSecrets === 1 ? "" : "s" }} missing
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
<Button class="w-full h-8 text-sm" @click="openInstall">
|
|
315
|
+
Use template
|
|
316
|
+
</Button>
|
|
317
|
+
<p class="text-xs text-muted-foreground">
|
|
318
|
+
Creates a paused copy in your workflows. Nothing runs until you activate it.
|
|
319
|
+
</p>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<div class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
|
|
323
|
+
<div class="text-mono text-muted-foreground">Publisher</div>
|
|
324
|
+
<div class="flex items-center gap-2">
|
|
325
|
+
<span class="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
326
|
+
<User :size="12" />
|
|
327
|
+
</span>
|
|
328
|
+
<div class="text-sm font-medium truncate">{{ tpl.publisher.name }}</div>
|
|
329
|
+
<Badge variant="outline" :class="['text-sm border', publisherBadgeClass(tpl.publisher.kind)]">
|
|
330
|
+
{{ PUBLISHER_KIND_LABEL[tpl.publisher.kind] ?? tpl.publisher.kind }}
|
|
331
|
+
</Badge>
|
|
332
|
+
</div>
|
|
333
|
+
<div v-if="packageLabel" class="border-t border-black/[0.06] pt-2 text-sm">
|
|
334
|
+
<div class="text-muted-foreground">Ships with</div>
|
|
335
|
+
<div class="font-medium">{{ packageLabel }}</div>
|
|
336
|
+
</div>
|
|
337
|
+
<div class="border-t border-black/[0.06] pt-2 grid grid-cols-2 gap-2 text-sm">
|
|
338
|
+
<div>
|
|
339
|
+
<div class="text-muted-foreground">Steps</div>
|
|
340
|
+
<div class="font-medium tabular-nums">{{ steps.length || tpl.nodeCount }}</div>
|
|
341
|
+
</div>
|
|
342
|
+
<div>
|
|
343
|
+
<div class="text-muted-foreground">Level</div>
|
|
344
|
+
<span class="mt-0.5 inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
|
|
345
|
+
<span class="size-1.5 rounded-full" :class="levelDot(tpl.level)" />
|
|
346
|
+
{{ tpl.level }}
|
|
347
|
+
</span>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<div v-if="credentialReqs.length" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
|
|
353
|
+
<div class="flex items-center gap-1.5 text-mono text-muted-foreground">
|
|
354
|
+
<Key :size="11" /> Credentials
|
|
355
|
+
</div>
|
|
356
|
+
<ul class="space-y-1">
|
|
357
|
+
<li
|
|
358
|
+
v-for="c in credentialReqs"
|
|
359
|
+
:key="c.slug"
|
|
360
|
+
class="flex items-center justify-between gap-2 rounded-md border border-black/[0.06] bg-muted/20 px-2 py-1 text-sm"
|
|
361
|
+
>
|
|
362
|
+
<span class="min-w-0">
|
|
363
|
+
<span class="block truncate font-medium">{{ c.name ?? c.slug }}</span>
|
|
364
|
+
</span>
|
|
365
|
+
<Badge
|
|
366
|
+
v-if="ofType(c.slug).length"
|
|
367
|
+
variant="outline"
|
|
368
|
+
class="shrink-0 bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm"
|
|
369
|
+
>
|
|
370
|
+
<CheckCircle2 :size="10" class="mr-0.5" />
|
|
371
|
+
{{ ofType(c.slug).length }} ready
|
|
372
|
+
</Badge>
|
|
373
|
+
<Badge v-else variant="outline" class="shrink-0 bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
|
|
374
|
+
Connect first
|
|
375
|
+
</Badge>
|
|
376
|
+
</li>
|
|
377
|
+
</ul>
|
|
378
|
+
<NuxtLink to="/integrations/credentials" class="block text-sm text-primary hover:underline">Manage credentials →</NuxtLink>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<div v-if="secretChecks.length" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
|
|
382
|
+
<div class="flex items-center gap-2">
|
|
383
|
+
<span class="text-mono text-muted-foreground">Required secrets</span>
|
|
384
|
+
<span v-if="missingSecrets > 0" class="flex items-center gap-1 text-sm text-amber-700">
|
|
385
|
+
<ShieldAlert :size="11" />
|
|
386
|
+
{{ missingSecrets }} missing
|
|
387
|
+
</span>
|
|
388
|
+
</div>
|
|
389
|
+
<ul class="space-y-1">
|
|
390
|
+
<li
|
|
391
|
+
v-for="(s, i) in secretChecks"
|
|
392
|
+
:key="s.key ?? `secret-${i}`"
|
|
393
|
+
class="flex items-center justify-between rounded-md border border-black/[0.06] bg-muted/20 px-2 py-1 text-sm"
|
|
394
|
+
>
|
|
395
|
+
<code class="font-mono">{{ s.label }}</code>
|
|
396
|
+
<Badge
|
|
397
|
+
v-if="s.present"
|
|
398
|
+
variant="outline"
|
|
399
|
+
class="bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm"
|
|
400
|
+
>
|
|
401
|
+
<CheckCircle2 :size="10" class="mr-0.5" />
|
|
402
|
+
Set
|
|
403
|
+
</Badge>
|
|
404
|
+
<Badge v-else variant="outline" class="bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
|
|
405
|
+
Missing
|
|
406
|
+
</Badge>
|
|
407
|
+
</li>
|
|
408
|
+
</ul>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<div v-if="tpl.industries.length || tpl.vendors.length" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
|
|
412
|
+
<div class="text-mono text-muted-foreground">Good fit for</div>
|
|
413
|
+
<div class="flex flex-wrap gap-1">
|
|
414
|
+
<span v-for="ind in tpl.industries" :key="`i-${ind}`" class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
|
|
415
|
+
{{ ind === "any" ? "Any industry" : ind }}
|
|
416
|
+
</span>
|
|
417
|
+
<span v-for="v in tpl.vendors" :key="`v-${v}`" class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
|
|
418
|
+
{{ v }}
|
|
419
|
+
</span>
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
</template>
|
|
426
|
+
<div v-else-if="!loaded" class="p-12 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
|
427
|
+
<Loader2 :size="14" class="animate-spin" />
|
|
428
|
+
Loading template…
|
|
429
|
+
</div>
|
|
430
|
+
<div v-else class="p-12 text-center text-sm text-muted-foreground">
|
|
431
|
+
Template not found.
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
<!-- Install: name + per-node credential binding ------------------------- -->
|
|
435
|
+
<Dialog v-model:open="installOpen">
|
|
436
|
+
<DialogContent class="sm:max-w-lg">
|
|
437
|
+
<DialogHeader>
|
|
438
|
+
<DialogTitle>Use template</DialogTitle>
|
|
439
|
+
<DialogDescription class="text-sm">
|
|
440
|
+
Pre-wire credentials, then we create the workflow. It starts inactive, so you can finish wiring in the editor and activate when ready.
|
|
441
|
+
</DialogDescription>
|
|
442
|
+
</DialogHeader>
|
|
443
|
+
|
|
444
|
+
<div class="space-y-4 py-1 max-h-[28rem] overflow-y-auto pr-1">
|
|
445
|
+
<div class="space-y-1.5">
|
|
446
|
+
<Label for="wf-name">Workflow name</Label>
|
|
447
|
+
<Input id="wf-name" v-model="name" class="bg-white" placeholder="Workflow name" />
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<!-- Credentials -->
|
|
451
|
+
<div class="space-y-2">
|
|
452
|
+
<div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
|
|
453
|
+
Credentials
|
|
454
|
+
</div>
|
|
455
|
+
<p v-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
|
|
456
|
+
This template needs no credentials.
|
|
457
|
+
</p>
|
|
458
|
+
<div v-for="req in credentialReqs" :key="req.slug" class="space-y-2">
|
|
459
|
+
<div class="flex items-center gap-2">
|
|
460
|
+
<span class="text-sm font-medium">{{ req.name ?? req.slug }}</span>
|
|
461
|
+
<Badge v-if="!req.registered" variant="outline" class="bg-rose-500/10 border-rose-500/20 text-rose-700 text-sm">
|
|
462
|
+
not registered
|
|
463
|
+
</Badge>
|
|
464
|
+
<Badge v-else-if="!ofType(req.slug).length" variant="outline" class="bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
|
|
465
|
+
none yet
|
|
466
|
+
</Badge>
|
|
467
|
+
</div>
|
|
468
|
+
|
|
469
|
+
<p v-if="!ofType(req.slug).length" class="rounded-md border-l-2 border-black/[0.08] bg-muted/30 px-2.5 py-1.5 text-sm text-muted-foreground">
|
|
470
|
+
No credentials of this type yet.
|
|
471
|
+
<NuxtLink to="/integrations/credentials" class="text-primary hover:underline">Create one</NuxtLink>
|
|
472
|
+
first, or install now and wire it up in the editor.
|
|
473
|
+
</p>
|
|
474
|
+
<div v-for="nodeId in nodesFor(req.usedBy)" v-else :key="nodeId" class="space-y-1">
|
|
475
|
+
<Label class="text-xs text-muted-foreground font-mono">node {{ nodeId }}</Label>
|
|
476
|
+
<Select
|
|
477
|
+
:model-value="bindings[nodeId] ?? SELECT_LATER"
|
|
478
|
+
@update:model-value="(v2) => {
|
|
479
|
+
bindings[nodeId] = v2 ?? SELECT_LATER;
|
|
480
|
+
}"
|
|
481
|
+
>
|
|
482
|
+
<SelectTrigger class="bg-white">
|
|
483
|
+
<SelectValue placeholder="Select later" />
|
|
484
|
+
</SelectTrigger>
|
|
485
|
+
<SelectContent>
|
|
486
|
+
<SelectItem :value="SELECT_LATER">Select later</SelectItem>
|
|
487
|
+
<SelectItem v-for="c in ofType(req.slug)" :key="c.id" :value="c.id">
|
|
488
|
+
{{ c.name }}
|
|
489
|
+
</SelectItem>
|
|
490
|
+
</SelectContent>
|
|
491
|
+
</Select>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
<!-- Secrets -->
|
|
497
|
+
<div v-if="secretChecks.length" class="space-y-2">
|
|
498
|
+
<div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
|
|
499
|
+
Secrets
|
|
500
|
+
</div>
|
|
501
|
+
<p class="text-sm text-muted-foreground">
|
|
502
|
+
Referenced secret keys must exist before the workflow runs. Manage them on the
|
|
503
|
+
<NuxtLink to="/integrations/secrets" class="text-primary hover:underline">Secrets</NuxtLink> page.
|
|
504
|
+
</p>
|
|
505
|
+
<ul class="space-y-1">
|
|
506
|
+
<li
|
|
507
|
+
v-for="(s, i) in secretChecks"
|
|
508
|
+
:key="s.key ?? `dlg-secret-${i}`"
|
|
509
|
+
class="flex items-center justify-between rounded-md border border-black/[0.06] bg-muted/20 px-2 py-1 text-sm"
|
|
510
|
+
>
|
|
511
|
+
<code class="font-mono">{{ s.label }}</code>
|
|
512
|
+
<Badge variant="outline" :class="s.present ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm' : 'bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm'">
|
|
513
|
+
{{ s.present ? "Stored" : "Missing" }}
|
|
514
|
+
</Badge>
|
|
515
|
+
</li>
|
|
516
|
+
</ul>
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
<div v-if="installError" class="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
|
|
520
|
+
{{ installError }}
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
<DialogFooter>
|
|
525
|
+
<Button variant="outline" :disabled="installing" @click="installOpen = false">
|
|
526
|
+
Cancel
|
|
527
|
+
</Button>
|
|
528
|
+
<Button :disabled="installing || !name.trim()" @click="confirmInstall">
|
|
529
|
+
<Loader2 v-if="installing" :size="14" class="mr-1 animate-spin" />
|
|
530
|
+
{{ installing ? "Setting up\u2026" : "Install" }}
|
|
531
|
+
</Button>
|
|
532
|
+
</DialogFooter>
|
|
533
|
+
</DialogContent>
|
|
534
|
+
</Dialog>
|
|
535
|
+
</IntegrationsLayout>
|
|
536
|
+
</template>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, ref, watch } from "vue";
|
|
3
|
+
import { Magnifier as Search, RefreshCircle as Loader2, Layers, DangerTriangle as AlertTriangle, Bolt as Zap, ClockCircle as Clock, Global as Webhook, Play } from "#solar-icons";
|
|
4
|
+
import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@revenexx/studio";
|
|
5
|
+
import TemplateFlowPreview from "../../../components/Integrations/TemplateFlowPreview.vue";
|
|
6
|
+
import { TEMPLATE_CATEGORIES } from "../../../data/integrationTemplates";
|
|
7
|
+
useHead({ title: "Templates \xB7 Integration Studio" });
|
|
8
|
+
const { templates, loading, error, loaded, load, ensureDetails } = useIntegrationsTemplates();
|
|
9
|
+
const { load: loadNodeCatalog } = useIntegrationsNodes();
|
|
10
|
+
onMounted(async () => {
|
|
11
|
+
await Promise.all([load(true), loadNodeCatalog()]);
|
|
12
|
+
void ensureDetails(templates.value.map((t) => t.slug));
|
|
13
|
+
});
|
|
14
|
+
const search = ref("");
|
|
15
|
+
const activeCategory = ref("all");
|
|
16
|
+
const activeLevel = ref("all");
|
|
17
|
+
const sortBy = ref("name");
|
|
18
|
+
const CATEGORY_LABELS = Object.fromEntries(
|
|
19
|
+
TEMPLATE_CATEGORIES.map((c) => [c.key, c.label])
|
|
20
|
+
);
|
|
21
|
+
function categoryLabel(key) {
|
|
22
|
+
return CATEGORY_LABELS[key] ?? key.charAt(0).toUpperCase() + key.slice(1);
|
|
23
|
+
}
|
|
24
|
+
const categories = computed(() => {
|
|
25
|
+
const counts = /* @__PURE__ */ new Map();
|
|
26
|
+
for (const t of templates.value) counts.set(t.category, (counts.get(t.category) ?? 0) + 1);
|
|
27
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([key, count]) => ({ key, label: categoryLabel(key), count }));
|
|
28
|
+
});
|
|
29
|
+
const LEVELS = ["beginner", "intermediate", "advanced"];
|
|
30
|
+
const LEVEL_RANK = { beginner: 0, intermediate: 1, advanced: 2 };
|
|
31
|
+
const filtered = computed(() => {
|
|
32
|
+
const q = search.value.trim().toLowerCase();
|
|
33
|
+
let items = templates.value;
|
|
34
|
+
if (activeCategory.value !== "all") items = items.filter((t) => t.category === activeCategory.value);
|
|
35
|
+
if (activeLevel.value !== "all") items = items.filter((t) => t.level === activeLevel.value);
|
|
36
|
+
if (q) {
|
|
37
|
+
items = items.filter(
|
|
38
|
+
(t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) || t.vendors.some((v) => v.includes(q)) || (packageLabel(t) ?? "").toLowerCase().includes(q) || t.industries.some((i) => i.includes(q))
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const sorted = [...items];
|
|
42
|
+
if (sortBy.value === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
|
|
43
|
+
else if (sortBy.value === "level") sorted.sort((a, b) => (LEVEL_RANK[a.level] ?? 0) - (LEVEL_RANK[b.level] ?? 0) || a.name.localeCompare(b.name));
|
|
44
|
+
else sorted.sort((a, b) => b.nodeCount - a.nodeCount || a.name.localeCompare(b.name));
|
|
45
|
+
return sorted;
|
|
46
|
+
});
|
|
47
|
+
watch(() => templates.value.length, () => {
|
|
48
|
+
if (templates.value.length) void ensureDetails(templates.value.map((t) => t.slug));
|
|
49
|
+
});
|
|
50
|
+
const TRIGGER_ICON = { schedule: Clock, webhook: Webhook, manual: Play, event: Zap };
|
|
51
|
+
const TRIGGER_LABEL = { schedule: "Scheduled", webhook: "Webhook", manual: "Run on demand", event: "Event-driven" };
|
|
52
|
+
function levelDot(level) {
|
|
53
|
+
return level === "beginner" ? "bg-emerald-500" : level === "intermediate" ? "bg-amber-500" : "bg-rose-500";
|
|
54
|
+
}
|
|
55
|
+
function bannerFor(tpl) {
|
|
56
|
+
return tpl.images.find((i) => i.category === "banner")?.src ?? null;
|
|
57
|
+
}
|
|
58
|
+
function packageLabel(tpl) {
|
|
59
|
+
const pkg = tpl.package;
|
|
60
|
+
if (!pkg) return null;
|
|
61
|
+
const tail = (pkg.name.split("/").pop() ?? pkg.name).replace(/^integrations-nodes-/, "");
|
|
62
|
+
if (tail === "core") return null;
|
|
63
|
+
return pkg.label ?? tail.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<template>
|
|
68
|
+
<IntegrationsLayout>
|
|
69
|
+
<!-- The toolbar lives in the header's #tabs slot: PageHeader is the
|
|
70
|
+
sticky element (top-0 z-20), so filters + search stay pinned. -->
|
|
71
|
+
<PageHeader
|
|
72
|
+
title="Templates"
|
|
73
|
+
description="Pre-built workflows you can clone, then tweak. Wired by revenexx and our partners."
|
|
74
|
+
>
|
|
75
|
+
<template #tabs>
|
|
76
|
+
<div class="-mx-3 w-[calc(100%+1.5rem)] flex items-center gap-3 border-t border-black/[0.06] px-4 pt-2.5 pb-1">
|
|
77
|
+
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
class="inline-flex h-7 shrink-0 items-center rounded-full border px-3 text-sm transition"
|
|
81
|
+
:class="activeCategory === 'all' ? 'border-primary/30 bg-primary/10 text-primary font-medium' : 'border-black/[0.08] text-muted-foreground hover:border-black/20'"
|
|
82
|
+
@click="activeCategory = 'all'"
|
|
83
|
+
>
|
|
84
|
+
All <span class="opacity-70">({{ templates.length }})</span>
|
|
85
|
+
</button>
|
|
86
|
+
<button
|
|
87
|
+
v-for="cat in categories"
|
|
88
|
+
:key="cat.key"
|
|
89
|
+
type="button"
|
|
90
|
+
class="inline-flex h-7 shrink-0 items-center rounded-full border px-3 text-sm transition"
|
|
91
|
+
:class="activeCategory === cat.key ? 'border-primary/30 bg-primary/10 text-primary font-medium' : 'border-black/[0.08] text-muted-foreground hover:border-black/20'"
|
|
92
|
+
@click="activeCategory = activeCategory === cat.key ? 'all' : cat.key"
|
|
93
|
+
>
|
|
94
|
+
{{ cat.label }} <span class="opacity-70">({{ cat.count }})</span>
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<Select v-model="activeLevel">
|
|
99
|
+
<SelectTrigger class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-36 rounded-full bg-white text-sm">
|
|
100
|
+
<SelectValue />
|
|
101
|
+
</SelectTrigger>
|
|
102
|
+
<SelectContent>
|
|
103
|
+
<SelectItem value="all">All levels</SelectItem>
|
|
104
|
+
<SelectItem v-for="l in LEVELS" :key="l" :value="l">{{ l.charAt(0).toUpperCase() + l.slice(1) }}</SelectItem>
|
|
105
|
+
</SelectContent>
|
|
106
|
+
</Select>
|
|
107
|
+
<Select v-model="sortBy">
|
|
108
|
+
<SelectTrigger class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-40 rounded-full bg-white text-sm">
|
|
109
|
+
<SelectValue />
|
|
110
|
+
</SelectTrigger>
|
|
111
|
+
<SelectContent>
|
|
112
|
+
<SelectItem value="name">Sort: A–Z</SelectItem>
|
|
113
|
+
<SelectItem value="level">Sort: Simplest first</SelectItem>
|
|
114
|
+
<SelectItem value="nodes">Sort: Most steps</SelectItem>
|
|
115
|
+
</SelectContent>
|
|
116
|
+
</Select>
|
|
117
|
+
<div class="relative w-60">
|
|
118
|
+
<Search :size="12" class="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
119
|
+
<Input v-model="search" placeholder="Search templates, vendors…" class="h-7 min-h-0 py-0 rounded-full pl-7 text-sm bg-white" />
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</template>
|
|
123
|
+
</PageHeader>
|
|
124
|
+
|
|
125
|
+
<div class="p-4">
|
|
126
|
+
<!-- Loading (first fetch) -->
|
|
127
|
+
<div v-if="loading && !templates.length" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
128
|
+
<div v-for="n in 6" :key="`sk-${n}`" class="rounded-lg border border-black/[0.06] bg-white p-0 overflow-hidden">
|
|
129
|
+
<div class="h-36 bg-black/[0.04] animate-pulse" />
|
|
130
|
+
<div class="p-4 space-y-2">
|
|
131
|
+
<div class="h-4 w-2/3 rounded bg-black/[0.07] animate-pulse" />
|
|
132
|
+
<div class="h-3 w-full rounded bg-black/[0.05] animate-pulse" />
|
|
133
|
+
<div class="h-3 w-1/2 rounded bg-black/[0.05] animate-pulse" />
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<!-- Fetch failed -->
|
|
139
|
+
<div v-else-if="error && !templates.length" class="rounded-lg border border-rose-500/20 bg-rose-500/5 p-4 text-sm text-rose-700 flex items-start gap-2">
|
|
140
|
+
<AlertTriangle :size="15" class="mt-0.5 shrink-0" />
|
|
141
|
+
<div>
|
|
142
|
+
<div class="font-medium">Couldn't load templates.</div>
|
|
143
|
+
<div class="text-rose-700/80">{{ error }}</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<!-- Empty registry -->
|
|
148
|
+
<div v-else-if="loaded && !templates.length" class="rounded-lg border border-black/[0.06] bg-white p-10 text-center">
|
|
149
|
+
<span class="mx-auto mb-3 flex size-10 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
|
|
150
|
+
<Layers :size="18" />
|
|
151
|
+
</span>
|
|
152
|
+
<div class="text-sm font-semibold text-foreground">No templates registered yet</div>
|
|
153
|
+
<p class="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
|
|
154
|
+
Templates ship with node packages. Once a package that publishes templates is installed, they show up here.
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Cards -->
|
|
159
|
+
<div v-else class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
|
160
|
+
<NuxtLink
|
|
161
|
+
v-for="tpl in filtered"
|
|
162
|
+
:key="tpl.slug"
|
|
163
|
+
:to="`/integrations/templates/${tpl.slug}`"
|
|
164
|
+
class="group flex flex-col overflow-hidden rounded-lg border border-black/[0.06] bg-white transition hover:border-black/20 hover:shadow-sm"
|
|
165
|
+
>
|
|
166
|
+
<!-- Live workflow preview (or the registry banner, when one ships) -->
|
|
167
|
+
<div class="relative h-36 shrink-0 border-b border-black/[0.06] bg-slate-50/70 [background-image:radial-gradient(circle,rgba(15,23,42,0.07)_1px,transparent_1px)] [background-size:14px_14px]">
|
|
168
|
+
<img
|
|
169
|
+
v-if="bannerFor(tpl)"
|
|
170
|
+
:src="bannerFor(tpl)"
|
|
171
|
+
alt=""
|
|
172
|
+
class="h-full w-full object-cover"
|
|
173
|
+
>
|
|
174
|
+
<TemplateFlowPreview v-else-if="tpl.detailLoaded" :blob="tpl.blob" :height="144" class="px-2" />
|
|
175
|
+
<div v-else class="flex h-full items-center justify-center">
|
|
176
|
+
<Loader2 :size="16" class="animate-spin text-muted-foreground/50" />
|
|
177
|
+
</div>
|
|
178
|
+
<div class="absolute left-2 top-2 flex items-center gap-1.5">
|
|
179
|
+
<span class="inline-flex items-center whitespace-nowrap rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
|
|
180
|
+
{{ categoryLabel(tpl.category) }}
|
|
181
|
+
</span>
|
|
182
|
+
<span
|
|
183
|
+
v-if="packageLabel(tpl)"
|
|
184
|
+
class="inline-flex items-center whitespace-nowrap rounded-full bg-violet-100 px-2 py-0.5 uppercase text-xs font-medium text-violet-700"
|
|
185
|
+
>
|
|
186
|
+
{{ packageLabel(tpl) }}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div class="flex min-h-0 flex-1 flex-col p-4">
|
|
192
|
+
<div class="text-sm font-semibold text-foreground line-clamp-1">{{ tpl.name }}</div>
|
|
193
|
+
<p class="mt-1 flex-1 text-sm text-muted-foreground line-clamp-2">{{ tpl.description }}</p>
|
|
194
|
+
|
|
195
|
+
<div class="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
|
|
196
|
+
<span
|
|
197
|
+
v-for="t in tpl.triggerTypes"
|
|
198
|
+
:key="t"
|
|
199
|
+
class="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-2 py-0.5 uppercase"
|
|
200
|
+
>
|
|
201
|
+
<component :is="TRIGGER_ICON[t] ?? Zap" :size="10" />
|
|
202
|
+
{{ TRIGGER_LABEL[t] ?? t }}
|
|
203
|
+
</span>
|
|
204
|
+
<span class="flex-1" />
|
|
205
|
+
<span class="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-2 py-0.5 uppercase">
|
|
206
|
+
<span class="size-1.5 rounded-full" :class="levelDot(tpl.level)" />
|
|
207
|
+
{{ tpl.level }}
|
|
208
|
+
</span>
|
|
209
|
+
<span v-if="tpl.publisher.kind !== 'revenexx'" class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase">{{ tpl.publisher.name }}</span>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</NuxtLink>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div v-if="templates.length && !filtered.length" class="mt-4 rounded-lg border border-black/[0.06] bg-white p-8 text-center text-sm text-muted-foreground">
|
|
216
|
+
No templates match these filters.
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</IntegrationsLayout>
|
|
220
|
+
</template>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -182,8 +182,10 @@ async function runFromEditor() {
|
|
|
182
182
|
await run(primaryMode.value);
|
|
183
183
|
}
|
|
184
184
|
const runPayloadOpen = ref(false);
|
|
185
|
-
const definedEvents = useDefinedEvents()
|
|
186
|
-
|
|
185
|
+
const definedEvents = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), load: async () => {
|
|
186
|
+
} };
|
|
187
|
+
const eventLog = typeof useIntegrationsEvents === "function" ? useIntegrationsEvents() : { events: ref([]), loadEvents: async () => {
|
|
188
|
+
} };
|
|
187
189
|
const prefillBusy = ref(null);
|
|
188
190
|
const eventTriggerTopic = computed(() => {
|
|
189
191
|
const trigger = (workflow.value?.blob.triggers ?? []).find((t) => t.type === "event");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revenexx/studio-integrations",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
|
|
6
6
|
"repository": {
|
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
"access": "public"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@nuxt/kit": "^4.0.0"
|
|
27
|
+
"@nuxt/kit": "^4.0.0",
|
|
28
|
+
"marked": "^18.0.5"
|
|
28
29
|
},
|
|
29
30
|
"peerDependencies": {
|
|
30
31
|
"@dagrejs/dagre": "^3.0.0",
|
|
31
32
|
"@revenexx/studio": "*",
|
|
32
|
-
"@revenexx/studio-shared": "^0.
|
|
33
|
+
"@revenexx/studio-shared": "^0.2.0",
|
|
33
34
|
"@solar-icons/nuxt": "^1.2.0",
|
|
34
35
|
"@tiptap/core": "^3.28.0",
|
|
35
36
|
"@tiptap/extension-document": "^3.28.0",
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@nuxt/module-builder": "^1.0.2",
|
|
54
|
+
"@playwright/test": "^1.57.0",
|
|
53
55
|
"@nuxt/kit": "^4.0.0",
|
|
54
56
|
"@nuxt/schema": "^4.0.0",
|
|
55
57
|
"nuxt": "^4.2.2",
|
|
@@ -60,6 +62,7 @@
|
|
|
60
62
|
"license": "SEE LICENSE IN LICENSE",
|
|
61
63
|
"scripts": {
|
|
62
64
|
"build": "nuxt-module-build build",
|
|
63
|
-
"test": "vitest run"
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"test:e2e": "playwright test"
|
|
64
67
|
}
|
|
65
68
|
}
|