feat: Ado 1296 spike credential setup in templates (#7786)
- Add a 'Setup template credentials' view to setup the credentials of a template before it is created
This commit is contained in:
@@ -110,12 +110,9 @@ export default defineComponent({
|
||||
this.credentialsStore.fetchAllCredentials(),
|
||||
this.credentialsStore.fetchCredentialTypes(false),
|
||||
this.externalSecretsStore.fetchAllSecrets(),
|
||||
this.nodeTypesStore.loadNodeTypesIfNotLoaded(),
|
||||
];
|
||||
|
||||
if (this.nodeTypesStore.allNodeTypes.length === 0) {
|
||||
loadPromises.push(this.nodeTypesStore.getNodeTypes());
|
||||
}
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
|
||||
await this.usersStore.fetchUsers(); // Can be loaded in the background, used for filtering
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import N8nNotice from 'n8n-design-system/components/N8nNotice';
|
||||
import type { AppCredentials } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { formatList } from '@/utils/formatters/listFormatter';
|
||||
import { useI18n } from '@/composables';
|
||||
|
||||
const i18n = useI18n();
|
||||
const store = useSetupTemplateStore();
|
||||
const { appCredentials } = storeToRefs(store);
|
||||
|
||||
const formatApp = (app: AppCredentials) => `<b>${app.credentials.length}x ${app.appName}</b>`;
|
||||
|
||||
const appNodeCounts = computed(() => {
|
||||
return formatList(appCredentials.value, {
|
||||
formatFn: formatApp,
|
||||
i18n,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-notice theme="info">
|
||||
<i18n-t tag="span" keypath="templateSetup.instructions" scope="global">
|
||||
<span v-html="appNodeCounts" />
|
||||
</i18n-t>
|
||||
</n8n-notice>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<i class="el-icon-success" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
i {
|
||||
color: var(--prim-color-alt-a);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import N8nHeading from 'n8n-design-system/components/N8nHeading';
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
import CredentialPicker from '@/components/CredentialPicker/CredentialPicker.vue';
|
||||
import IconSuccess from './IconSuccess.vue';
|
||||
import { assert } from '@/utils/assert';
|
||||
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
|
||||
import { formatList } from '@/utils/formatters/listFormatter';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import type { IWorkflowTemplateNode } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
order: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
credentialName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Stores
|
||||
const setupTemplateStore = useSetupTemplateStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const i18n = useI18n();
|
||||
|
||||
//#region Computed
|
||||
|
||||
const credentials = computed(() => {
|
||||
const credential = setupTemplateStore.credentialsByName.get(props.credentialName);
|
||||
assert(credential);
|
||||
return credential;
|
||||
});
|
||||
|
||||
const node = computed(() => credentials.value.usedBy[0]);
|
||||
|
||||
const nodeType = computed(() =>
|
||||
nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion),
|
||||
);
|
||||
|
||||
const credentialType = computed(() => credentials.value.credentialType);
|
||||
|
||||
const appName = computed(() =>
|
||||
nodeType.value ? getAppNameFromNodeName(nodeType.value.displayName) : node.value.type,
|
||||
);
|
||||
|
||||
const nodeNames = computed(() => {
|
||||
const formatNodeName = (nodeToFormat: IWorkflowTemplateNode) => `<b>${nodeToFormat.name}</b>`;
|
||||
return formatList(credentials.value.usedBy, {
|
||||
formatFn: formatNodeName,
|
||||
i18n,
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCredentialId = computed(
|
||||
() => setupTemplateStore.selectedCredentialIdByName[props.credentialName],
|
||||
);
|
||||
|
||||
//#endregion Computed
|
||||
|
||||
//#region Methods
|
||||
|
||||
const onCredentialSelected = (credentialId: string) => {
|
||||
setupTemplateStore.setSelectedCredentialId(props.credentialName, credentialId);
|
||||
};
|
||||
|
||||
const onCredentialDeselected = () => {
|
||||
setupTemplateStore.unsetSelectedCredential(props.credentialName);
|
||||
};
|
||||
|
||||
//#endregion Methods
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="$style.container">
|
||||
<n8n-heading tag="h2" size="large">
|
||||
<div v-if="nodeType" :class="$style.heading">
|
||||
<span :class="$style.headingOrder">{{ order }}.</span>
|
||||
<span :class="$style.headingIcon"><NodeIcon :node-type="nodeType" /></span>
|
||||
{{ appName }}
|
||||
</div>
|
||||
</n8n-heading>
|
||||
|
||||
<p :class="$style.description">
|
||||
<i18n-t
|
||||
tag="span"
|
||||
keypath="templateSetup.credential.description"
|
||||
:plural="credentials.usedBy.length"
|
||||
scope="global"
|
||||
>
|
||||
<span v-html="nodeNames" />
|
||||
</i18n-t>
|
||||
</p>
|
||||
|
||||
<div :class="$style.credentials">
|
||||
<CredentialPicker
|
||||
:class="$style.credentialPicker"
|
||||
:app-name="appName"
|
||||
:credentialType="credentialType"
|
||||
:selectedCredentialId="selectedCredentialId"
|
||||
@credential-selected="onCredentialSelected"
|
||||
@credential-deselected="onCredentialDeselected"
|
||||
/>
|
||||
|
||||
<IconSuccess
|
||||
:class="{
|
||||
[$style.credentialOk]: true,
|
||||
[$style.invisible]: !selectedCredentialId,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.headingOrder {
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.headingIcon {
|
||||
margin-right: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
|
||||
.credentials {
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.credentialPicker {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.credentialOk {
|
||||
margin-left: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useSetupTemplateStore } from './setupTemplate.store';
|
||||
import N8nHeading from 'n8n-design-system/components/N8nHeading';
|
||||
import N8nLink from 'n8n-design-system/components/N8nLink';
|
||||
import AppsRequiringCredsNotice from './AppsRequiringCredsNotice.vue';
|
||||
import SetupTemplateFormStep from './SetupTemplateFormStep.vue';
|
||||
import TemplatesView from '../TemplatesView.vue';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useExternalHooks, useI18n, useTelemetry } from '@/composables';
|
||||
|
||||
// Store
|
||||
const setupTemplateStore = useSetupTemplateStore();
|
||||
const i18n = useI18n();
|
||||
const $telemetry = useTelemetry();
|
||||
const $externalHooks = useExternalHooks();
|
||||
|
||||
// Router
|
||||
const route = useRoute();
|
||||
const $router = useRouter();
|
||||
|
||||
//#region Computed
|
||||
|
||||
const templateId = computed(() =>
|
||||
Array.isArray(route.params.id) ? route.params.id[0] : route.params.id,
|
||||
);
|
||||
const title = computed(() => setupTemplateStore.template?.name ?? 'unknown');
|
||||
const isReady = computed(() => !setupTemplateStore.isLoading);
|
||||
|
||||
const skipSetupUrl = computed(() => {
|
||||
const resolvedRoute = $router.resolve({
|
||||
name: VIEWS.TEMPLATE_IMPORT,
|
||||
params: { id: templateId.value },
|
||||
});
|
||||
return resolvedRoute.fullPath;
|
||||
});
|
||||
|
||||
const buttonTooltip = computed(() => {
|
||||
const numLeft = setupTemplateStore.numCredentialsLeft;
|
||||
|
||||
return i18n.baseText('templateSetup.continue.tooltip', {
|
||||
adjustToNumber: numLeft,
|
||||
interpolate: { numLeft: numLeft.toString() },
|
||||
});
|
||||
});
|
||||
|
||||
//#endregion Computed
|
||||
|
||||
//#region Watchers
|
||||
|
||||
watch(templateId, async (newTemplateId) => {
|
||||
setupTemplateStore.setTemplateId(newTemplateId);
|
||||
await setupTemplateStore.loadTemplateIfNeeded();
|
||||
});
|
||||
|
||||
//#endregion Watchers
|
||||
|
||||
//#region Methods
|
||||
|
||||
const onSkipSetup = async (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
await setupTemplateStore.skipSetup({
|
||||
$externalHooks,
|
||||
$telemetry,
|
||||
$router,
|
||||
});
|
||||
};
|
||||
|
||||
const skipIfTemplateHasNoCreds = async () => {
|
||||
const isTemplateLoaded = !!setupTemplateStore.template;
|
||||
if (!isTemplateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (setupTemplateStore.credentialUsages.length === 0) {
|
||||
await setupTemplateStore.skipSetup({
|
||||
$externalHooks,
|
||||
$telemetry,
|
||||
$router,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
//#endregion Methods
|
||||
|
||||
//#region Lifecycle hooks
|
||||
|
||||
setupTemplateStore.setTemplateId(templateId.value);
|
||||
|
||||
onMounted(async () => {
|
||||
await setupTemplateStore.init();
|
||||
await skipIfTemplateHasNoCreds();
|
||||
});
|
||||
|
||||
//#endregion Lifecycle hooks
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TemplatesView :goBackEnabled="true">
|
||||
<template #header>
|
||||
<n8n-heading v-if="isReady" tag="h1" size="2xlarge"
|
||||
>{{ $locale.baseText('templateSetup.title', { interpolate: { name: title } }) }}
|
||||
</n8n-heading>
|
||||
<n8n-loading v-else variant="h1" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div :class="$style.grid">
|
||||
<div :class="$style.gridContent">
|
||||
<div :class="$style.notice">
|
||||
<AppsRequiringCredsNotice v-if="isReady" />
|
||||
<n8n-loading v-else variant="p" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ol v-if="isReady" :class="$style.appCredentialsContainer">
|
||||
<SetupTemplateFormStep
|
||||
:class="$style.appCredential"
|
||||
v-bind:key="credentials.credentialName"
|
||||
v-for="(credentials, index) in setupTemplateStore.credentialUsages"
|
||||
:order="index + 1"
|
||||
:credentials="credentials"
|
||||
:credentialName="credentials.credentialName"
|
||||
/>
|
||||
</ol>
|
||||
<div v-else :class="$style.appCredentialsContainer">
|
||||
<n8n-loading :class="$style.appCredential" variant="p" :rows="3" />
|
||||
<n8n-loading :class="$style.appCredential" variant="p" :rows="3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="$style.actions">
|
||||
<n8n-link :href="skipSetupUrl" :newWindow="false" @click="onSkipSetup($event)">{{
|
||||
$locale.baseText('templateSetup.skip')
|
||||
}}</n8n-link>
|
||||
|
||||
<n8n-tooltip
|
||||
v-if="isReady"
|
||||
:content="buttonTooltip"
|
||||
:disabled="setupTemplateStore.numCredentialsLeft === 0"
|
||||
>
|
||||
<n8n-button
|
||||
:label="$locale.baseText('templateSetup.continue.button')"
|
||||
:disabled="setupTemplateStore.numCredentialsLeft > 0 || setupTemplateStore.isSaving"
|
||||
@click="setupTemplateStore.createWorkflow($router)"
|
||||
/>
|
||||
</n8n-tooltip>
|
||||
<div v-else>
|
||||
<n8n-loading variant="button" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TemplatesView>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
padding: var(--spacing-l) var(--spacing-l) 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gridContent {
|
||||
grid-column: 3 / span 8;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
grid-column: 3 / span 8;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-column: 2 / span 10;
|
||||
}
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.appCredentialsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.appCredential:not(:last-of-type) {
|
||||
padding-bottom: var(--spacing-2xl);
|
||||
border-bottom: 1px solid var(--prim-gray-540);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: var(--spacing-3xl);
|
||||
margin-bottom: var(--spacing-3xl);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms';
|
||||
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
import {
|
||||
getAppCredentials,
|
||||
getAppsRequiringCredentials,
|
||||
groupNodeCredentialsByName,
|
||||
} from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
|
||||
|
||||
const objToMap = <T>(obj: Record<string, T>) => {
|
||||
return new Map<string, T>(Object.entries(obj));
|
||||
};
|
||||
|
||||
describe('SetupWorkflowFromTemplateView store', () => {
|
||||
const nodesByName = {
|
||||
Twitter: {
|
||||
name: 'Twitter',
|
||||
type: 'n8n-nodes-base.twitter',
|
||||
position: [720, -220],
|
||||
parameters: {
|
||||
text: '=Hey there, my design is now on a new product ✨\nVisit my {{$json["vendor"]}} shop to get this cool{{$json["title"]}} (and check out more {{$json["product_type"]}}) 🛍️',
|
||||
additionalFields: {},
|
||||
},
|
||||
credentials: {
|
||||
twitterOAuth1Api: 'twitter',
|
||||
},
|
||||
typeVersion: 1,
|
||||
},
|
||||
Telegram: {
|
||||
name: 'Telegram',
|
||||
type: 'n8n-nodes-base.telegram',
|
||||
position: [720, -20],
|
||||
parameters: {
|
||||
text: '=Hey there, my design is now on a new product!\nVisit my {{$json["vendor"]}} shop to get this cool{{$json["title"]}} (and check out more {{$json["product_type"]}})',
|
||||
chatId: '123456',
|
||||
additionalFields: {},
|
||||
},
|
||||
credentials: {
|
||||
telegramApi: 'telegram',
|
||||
},
|
||||
typeVersion: 1,
|
||||
},
|
||||
shopify: {
|
||||
name: 'shopify',
|
||||
type: 'n8n-nodes-base.shopifyTrigger',
|
||||
position: [540, -110],
|
||||
webhookId: '2a7e0e50-8f09-4a2b-bf54-a849a6ac4fe0',
|
||||
parameters: {
|
||||
topic: 'products/create',
|
||||
},
|
||||
credentials: {
|
||||
shopifyApi: 'shopify',
|
||||
},
|
||||
typeVersion: 1,
|
||||
},
|
||||
} satisfies Record<string, IWorkflowTemplateNodeWithCredentials>;
|
||||
|
||||
describe('groupNodeCredentialsByName', () => {
|
||||
it('returns an empty array if there are no nodes', () => {
|
||||
expect(groupNodeCredentialsByName([])).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('returns credentials grouped by name', () => {
|
||||
expect(groupNodeCredentialsByName(Object.values(nodesByName))).toEqual(
|
||||
objToMap({
|
||||
twitter: {
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
telegram: {
|
||||
credentialName: 'telegram',
|
||||
credentialType: 'telegramApi',
|
||||
nodeTypeName: 'n8n-nodes-base.telegram',
|
||||
usedBy: [nodesByName.Telegram],
|
||||
},
|
||||
shopify: {
|
||||
credentialName: 'shopify',
|
||||
credentialType: 'shopifyApi',
|
||||
nodeTypeName: 'n8n-nodes-base.shopifyTrigger',
|
||||
usedBy: [nodesByName.shopify],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppsRequiringCredentials', () => {
|
||||
it('returns an empty array if there are no nodes', () => {
|
||||
const appNameByNodeTypeName = () => 'Twitter';
|
||||
expect(getAppsRequiringCredentials(new Map(), appNameByNodeTypeName)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an array of apps requiring credentials', () => {
|
||||
const credentialUsages: Map<string, CredentialUsages> = objToMap({
|
||||
twitter: {
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
});
|
||||
|
||||
const appNameByNodeTypeName = () => 'Twitter';
|
||||
|
||||
expect(getAppsRequiringCredentials(credentialUsages, appNameByNodeTypeName)).toEqual([
|
||||
{
|
||||
appName: 'Twitter',
|
||||
count: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppCredentials', () => {
|
||||
it('returns an empty array if there are no nodes', () => {
|
||||
const appNameByNodeTypeName = () => 'Twitter';
|
||||
expect(getAppCredentials([], appNameByNodeTypeName)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an array of apps requiring credentials', () => {
|
||||
const credentialUsages: CredentialUsages[] = [
|
||||
{
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
];
|
||||
|
||||
const appNameByNodeTypeName = () => 'Twitter';
|
||||
|
||||
expect(getAppCredentials(credentialUsages, appNameByNodeTypeName)).toEqual([
|
||||
{
|
||||
appName: 'Twitter',
|
||||
credentials: [
|
||||
{
|
||||
credentialName: 'twitter',
|
||||
credentialType: 'twitterOAuth1Api',
|
||||
nodeTypeName: 'n8n-nodes-base.twitter',
|
||||
usedBy: [nodesByName.Twitter],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import sortBy from 'lodash-es/sortBy';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { Router } from 'vue-router';
|
||||
import {
|
||||
useCredentialsStore,
|
||||
useNodeTypesStore,
|
||||
useRootStore,
|
||||
useTemplatesStore,
|
||||
useWorkflowsStore,
|
||||
} from '@/stores';
|
||||
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
|
||||
import type { INodeCredentialsDetails, INodeTypeDescription } from 'n8n-workflow';
|
||||
import type {
|
||||
ICredentialsResponse,
|
||||
IExternalHooks,
|
||||
INodeUi,
|
||||
ITemplatesWorkflowFull,
|
||||
IWorkflowTemplateNode,
|
||||
} from '@/Interface';
|
||||
import type { Telemetry } from '@/plugins/telemetry';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { createWorkflowFromTemplate } from '@/utils/templates/templateActions';
|
||||
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms';
|
||||
import {
|
||||
hasNodeCredentials,
|
||||
normalizeTemplateNodeCredentials,
|
||||
} from '@/utils/templates/templateTransforms';
|
||||
|
||||
export type NodeAndType = {
|
||||
node: INodeUi;
|
||||
nodeType: INodeTypeDescription;
|
||||
};
|
||||
|
||||
export type RequiredCredentials = {
|
||||
node: INodeUi;
|
||||
credentialName: string;
|
||||
credentialType: string;
|
||||
};
|
||||
|
||||
export type CredentialUsages = {
|
||||
credentialName: string;
|
||||
credentialType: string;
|
||||
nodeTypeName: string;
|
||||
usedBy: IWorkflowTemplateNode[];
|
||||
};
|
||||
|
||||
export type AppCredentials = {
|
||||
appName: string;
|
||||
credentials: CredentialUsages[];
|
||||
};
|
||||
|
||||
export type AppCredentialCount = {
|
||||
appName: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
//#region Getter functions
|
||||
|
||||
export const getNodesRequiringCredentials = (
|
||||
template: ITemplatesWorkflowFull,
|
||||
): IWorkflowTemplateNodeWithCredentials[] => {
|
||||
if (!template) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return template.workflow.nodes.filter(hasNodeCredentials);
|
||||
};
|
||||
|
||||
export const groupNodeCredentialsByName = (nodes: IWorkflowTemplateNodeWithCredentials[]) => {
|
||||
const credentialsByName = new Map<string, CredentialUsages>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const normalizedCreds = normalizeTemplateNodeCredentials(node.credentials);
|
||||
for (const credentialType in normalizedCreds) {
|
||||
const credentialName = normalizedCreds[credentialType];
|
||||
|
||||
let credentialUsages = credentialsByName.get(credentialName);
|
||||
if (!credentialUsages) {
|
||||
credentialUsages = {
|
||||
nodeTypeName: node.type,
|
||||
credentialName,
|
||||
credentialType,
|
||||
usedBy: [],
|
||||
};
|
||||
credentialsByName.set(credentialName, credentialUsages);
|
||||
}
|
||||
|
||||
credentialUsages.usedBy.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return credentialsByName;
|
||||
};
|
||||
|
||||
export const getAppCredentials = (
|
||||
credentialUsages: CredentialUsages[],
|
||||
getAppNameByNodeType: (nodeTypeName: string, version?: number) => string,
|
||||
) => {
|
||||
const credentialsByAppName = new Map<string, AppCredentials>();
|
||||
|
||||
for (const credentialUsage of credentialUsages) {
|
||||
const nodeTypeName = credentialUsage.nodeTypeName;
|
||||
|
||||
const appName = getAppNameByNodeType(nodeTypeName) ?? nodeTypeName;
|
||||
const appCredentials = credentialsByAppName.get(appName);
|
||||
if (appCredentials) {
|
||||
appCredentials.credentials.push(credentialUsage);
|
||||
} else {
|
||||
credentialsByAppName.set(appName, {
|
||||
appName,
|
||||
credentials: [credentialUsage],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(credentialsByAppName.values());
|
||||
};
|
||||
|
||||
export const getAppsRequiringCredentials = (
|
||||
credentialUsagesByName: Map<string, CredentialUsages>,
|
||||
getAppNameByNodeType: (nodeTypeName: string, version?: number) => string,
|
||||
) => {
|
||||
const credentialsByAppName = new Map<string, AppCredentialCount>();
|
||||
|
||||
for (const credentialUsage of credentialUsagesByName.values()) {
|
||||
const node = credentialUsage.usedBy[0];
|
||||
|
||||
const appName = getAppNameByNodeType(node.type, node.typeVersion) ?? node.type;
|
||||
const appCredentials = credentialsByAppName.get(appName);
|
||||
if (appCredentials) {
|
||||
appCredentials.count++;
|
||||
} else {
|
||||
credentialsByAppName.set(appName, {
|
||||
appName,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(credentialsByAppName.values());
|
||||
};
|
||||
|
||||
//#endregion Getter functions
|
||||
|
||||
/**
|
||||
* Store for managing the state of the SetupWorkflowFromTemplateView
|
||||
*/
|
||||
export const useSetupTemplateStore = defineStore('setupTemplate', () => {
|
||||
//#region State
|
||||
const templateId = ref<string>('');
|
||||
const isLoading = ref(true);
|
||||
const isSaving = ref(false);
|
||||
|
||||
/**
|
||||
* Credentials user has selected from the UI. Map from credential
|
||||
* name in the template to the credential ID.
|
||||
*/
|
||||
const selectedCredentialIdByName = ref<
|
||||
Record<CredentialUsages['credentialName'], ICredentialsResponse['id']>
|
||||
>({});
|
||||
|
||||
//#endregion State
|
||||
|
||||
const templatesStore = useTemplatesStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const rootStore = useRootStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
//#region Getters
|
||||
|
||||
const template = computed(() => {
|
||||
return templateId.value ? templatesStore.getFullTemplateById(templateId.value) : null;
|
||||
});
|
||||
|
||||
const nodesRequiringCredentialsSorted = computed(() => {
|
||||
const credentials = template.value ? getNodesRequiringCredentials(template.value) : [];
|
||||
|
||||
// Order by the X coordinate of the node
|
||||
return sortBy(credentials, ({ position }) => position[0]);
|
||||
});
|
||||
|
||||
const appNameByNodeType = (nodeTypeName: string, version?: number) => {
|
||||
const nodeType = nodeTypesStore.getNodeType(nodeTypeName, version);
|
||||
|
||||
return nodeType ? getAppNameFromNodeName(nodeType.displayName) : nodeTypeName;
|
||||
};
|
||||
|
||||
const credentialsByName = computed(() => {
|
||||
return groupNodeCredentialsByName(nodesRequiringCredentialsSorted.value);
|
||||
});
|
||||
|
||||
const credentialUsages = computed(() => {
|
||||
return Array.from(credentialsByName.value.values());
|
||||
});
|
||||
|
||||
const appCredentials = computed(() => {
|
||||
return getAppCredentials(credentialUsages.value, appNameByNodeType);
|
||||
});
|
||||
|
||||
const credentialOverrides = computed(() => {
|
||||
const overrides: Record<string, INodeCredentialsDetails> = {};
|
||||
|
||||
for (const credentialNameInTemplate of Object.keys(selectedCredentialIdByName.value)) {
|
||||
const credentialId = selectedCredentialIdByName.value[credentialNameInTemplate];
|
||||
if (!credentialId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const credential = credentialsStore.getCredentialById(credentialId);
|
||||
if (!credential) {
|
||||
continue;
|
||||
}
|
||||
|
||||
overrides[credentialNameInTemplate] = {
|
||||
id: credentialId,
|
||||
name: credential.name,
|
||||
};
|
||||
}
|
||||
|
||||
return overrides;
|
||||
});
|
||||
|
||||
const numCredentialsLeft = computed(() => {
|
||||
return credentialUsages.value.length - Object.keys(selectedCredentialIdByName.value).length;
|
||||
});
|
||||
|
||||
//#endregion Getters
|
||||
|
||||
//#region Actions
|
||||
|
||||
const setTemplateId = (id: string) => {
|
||||
templateId.value = id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the template if it hasn't been loaded yet.
|
||||
*/
|
||||
const loadTemplateIfNeeded = async () => {
|
||||
if (!!template.value || !templateId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await templatesStore.fetchTemplateById(templateId.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes the store for a specific template.
|
||||
*/
|
||||
const init = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
selectedCredentialIdByName.value = {};
|
||||
|
||||
await Promise.all([
|
||||
credentialsStore.fetchAllCredentials(),
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
nodeTypesStore.loadNodeTypesIfNotLoaded(),
|
||||
loadTemplateIfNeeded(),
|
||||
]);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Skips the setup and goes directly to the workflow view.
|
||||
*/
|
||||
const skipSetup = async (opts: {
|
||||
$externalHooks: IExternalHooks;
|
||||
$telemetry: Telemetry;
|
||||
$router: Router;
|
||||
}) => {
|
||||
const { $externalHooks, $telemetry, $router } = opts;
|
||||
const telemetryPayload = {
|
||||
source: 'workflow',
|
||||
template_id: templateId.value,
|
||||
wf_template_repo_session_id: templatesStore.currentSessionId,
|
||||
};
|
||||
|
||||
await $externalHooks.run('templatesWorkflowView.openWorkflow', telemetryPayload);
|
||||
$telemetry.track('User inserted workflow template', telemetryPayload, {
|
||||
withPostHog: true,
|
||||
});
|
||||
|
||||
// Replace the URL so back button doesn't come back to this setup view
|
||||
await $router.replace({
|
||||
name: VIEWS.TEMPLATE_IMPORT,
|
||||
params: { id: templateId.value },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a workflow from the template and navigates to the workflow view.
|
||||
*/
|
||||
const createWorkflow = async ($router: Router) => {
|
||||
if (!template.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving.value = true;
|
||||
|
||||
const createdWorkflow = await createWorkflowFromTemplate(
|
||||
template.value,
|
||||
credentialOverrides.value,
|
||||
rootStore,
|
||||
workflowsStore,
|
||||
);
|
||||
|
||||
// Replace the URL so back button doesn't come back to this setup view
|
||||
await $router.replace({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: createdWorkflow.id },
|
||||
});
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setSelectedCredentialId = (credentialName: string, credentialId: string) => {
|
||||
selectedCredentialIdByName.value[credentialName] = credentialId;
|
||||
};
|
||||
|
||||
const unsetSelectedCredential = (credentialName: string) => {
|
||||
delete selectedCredentialIdByName.value[credentialName];
|
||||
};
|
||||
|
||||
//#endregion Actions
|
||||
|
||||
return {
|
||||
credentialsByName,
|
||||
isLoading,
|
||||
isSaving,
|
||||
appCredentials,
|
||||
nodesRequiringCredentialsSorted,
|
||||
template,
|
||||
credentialUsages,
|
||||
selectedCredentialIdByName,
|
||||
numCredentialsLeft,
|
||||
createWorkflow,
|
||||
skipSetup,
|
||||
init,
|
||||
loadTemplateIfNeeded,
|
||||
setTemplateId,
|
||||
setSelectedCredentialId,
|
||||
unsetSelectedCredential,
|
||||
};
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
v-if="template"
|
||||
:label="$locale.baseText('template.buttons.useThisWorkflowButton')"
|
||||
size="large"
|
||||
@click="openWorkflow(template.id, $event)"
|
||||
@click="openTemplateSetup(template.id, $event)"
|
||||
/>
|
||||
<n8n-loading :loading="!template" :rows="1" variant="button" />
|
||||
</div>
|
||||
@@ -68,6 +68,7 @@ import { setPageTitle } from '@/utils';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
import { FeatureFlag, isFeatureFlagEnabled } from '@/utils/featureFlag';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TemplatesWorkflowView',
|
||||
@@ -94,7 +95,7 @@ export default defineComponent({
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
openWorkflow(id: string, e: PointerEvent) {
|
||||
openTemplateSetup(id: string, e: PointerEvent) {
|
||||
const telemetryPayload = {
|
||||
source: 'workflow',
|
||||
template_id: id,
|
||||
@@ -105,12 +106,23 @@ export default defineComponent({
|
||||
this.$telemetry.track('User inserted workflow template', telemetryPayload, {
|
||||
withPostHog: true,
|
||||
});
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
const route = this.$router.resolve({ name: VIEWS.TEMPLATE_IMPORT, params: { id } });
|
||||
window.open(route.href, '_blank');
|
||||
return;
|
||||
|
||||
if (isFeatureFlagEnabled(FeatureFlag.templateCredentialsSetup)) {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
const route = this.$router.resolve({ name: VIEWS.TEMPLATE_SETUP, params: { id } });
|
||||
window.open(route.href, '_blank');
|
||||
return;
|
||||
} else {
|
||||
void this.$router.push({ name: VIEWS.TEMPLATE_SETUP, params: { id } });
|
||||
}
|
||||
} else {
|
||||
void this.$router.push({ name: VIEWS.TEMPLATE_IMPORT, params: { id } });
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
const route = this.$router.resolve({ name: VIEWS.TEMPLATE_IMPORT, params: { id } });
|
||||
window.open(route.href, '_blank');
|
||||
return;
|
||||
} else {
|
||||
void this.$router.push({ name: VIEWS.TEMPLATE_IMPORT, params: { id } });
|
||||
}
|
||||
}
|
||||
},
|
||||
onHidePreview() {
|
||||
|
||||
Reference in New Issue
Block a user