mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-26 05:04:05 -08:00
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:
parent
27e048c201
commit
aae45b043b
|
@ -45,6 +45,7 @@ import type {
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
INodeProperties,
|
INodeProperties,
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
|
INodeCredentialsDetails,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { BulkCommand, Undoable } from '@/models/history';
|
import type { BulkCommand, Undoable } from '@/models/history';
|
||||||
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
|
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
|
||||||
|
@ -248,10 +249,22 @@ export interface IWorkflowToShare extends IWorkflowDataUpdate {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IWorkflowTemplateNode
|
||||||
|
extends Pick<INodeUi, 'name' | 'type' | 'position' | 'parameters' | 'typeVersion' | 'webhookId'> {
|
||||||
|
// The credentials in a template workflow have a different type than in a regular workflow
|
||||||
|
credentials?: IWorkflowTemplateNodeCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IWorkflowTemplateNodeCredentials {
|
||||||
|
[key: string]: string | INodeCredentialsDetails;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IWorkflowTemplate {
|
export interface IWorkflowTemplate {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
workflow: Pick<IWorkflowData, 'nodes' | 'connections' | 'settings' | 'pinData'>;
|
workflow: Pick<IWorkflowData, 'connections' | 'settings' | 'pinData'> & {
|
||||||
|
nodes: IWorkflowTemplateNode[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface INewWorkflowData {
|
export interface INewWorkflowData {
|
||||||
|
@ -790,6 +803,9 @@ export interface ITemplatesCollectionResponse extends ITemplatesCollectionExtend
|
||||||
workflows: ITemplatesWorkflow[];
|
workflows: ITemplatesWorkflow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A template without the actual workflow definition
|
||||||
|
*/
|
||||||
export interface ITemplatesWorkflow {
|
export interface ITemplatesWorkflow {
|
||||||
id: number;
|
id: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
@ -807,6 +823,9 @@ export interface ITemplatesWorkflowResponse extends ITemplatesWorkflow, IWorkflo
|
||||||
categories: ITemplatesCategory[];
|
categories: ITemplatesCategory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A template with also the full workflow definition
|
||||||
|
*/
|
||||||
export interface ITemplatesWorkflowFull extends ITemplatesWorkflowResponse {
|
export interface ITemplatesWorkflowFull extends ITemplatesWorkflowResponse {
|
||||||
full: true;
|
full: true;
|
||||||
}
|
}
|
||||||
|
@ -1302,7 +1321,7 @@ export interface INodeTypesState {
|
||||||
export interface ITemplateState {
|
export interface ITemplateState {
|
||||||
categories: { [id: string]: ITemplatesCategory };
|
categories: { [id: string]: ITemplatesCategory };
|
||||||
collections: { [id: string]: ITemplatesCollection };
|
collections: { [id: string]: ITemplatesCollection };
|
||||||
workflows: { [id: string]: ITemplatesWorkflow };
|
workflows: { [id: string]: ITemplatesWorkflow | ITemplatesWorkflowFull };
|
||||||
workflowSearches: {
|
workflowSearches: {
|
||||||
[search: string]: {
|
[search: string]: {
|
||||||
workflowIds: string[];
|
workflowIds: string[];
|
||||||
|
|
|
@ -0,0 +1,134 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useUIStore } from '@/stores';
|
||||||
|
import { listenForCredentialChanges, useCredentialsStore } from '@/stores/credentials.store';
|
||||||
|
import { assert } from '@/utils/assert';
|
||||||
|
import CredentialsDropdown from './CredentialsDropdown.vue';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
appName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
credentialType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
selectedCredentialId: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const $emit = defineEmits({
|
||||||
|
credentialSelected: (_credentialId: string) => true,
|
||||||
|
credentialDeselected: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const credentialsStore = useCredentialsStore();
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const availableCredentials = computed(() => {
|
||||||
|
return credentialsStore.getCredentialsByType(props.credentialType);
|
||||||
|
});
|
||||||
|
|
||||||
|
const credentialOptions = computed(() => {
|
||||||
|
return availableCredentials.value.map((credential) => ({
|
||||||
|
id: credential.id,
|
||||||
|
name: credential.name,
|
||||||
|
typeDisplayName: credentialsStore.getCredentialTypeByName(credential.type)?.displayName,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCredentialSelected = (credentialId: string) => {
|
||||||
|
$emit('credentialSelected', credentialId);
|
||||||
|
};
|
||||||
|
const createNewCredential = () => {
|
||||||
|
uiStore.openNewCredential(props.credentialType, true);
|
||||||
|
};
|
||||||
|
const editCredential = () => {
|
||||||
|
assert(props.selectedCredentialId);
|
||||||
|
uiStore.openExistingCredential(props.selectedCredentialId);
|
||||||
|
};
|
||||||
|
|
||||||
|
listenForCredentialChanges({
|
||||||
|
store: credentialsStore,
|
||||||
|
onCredentialCreated: (credential) => {
|
||||||
|
// TODO: We should have a better way to detect if credential created was due to
|
||||||
|
// user opening the credential modal from this component, as there might be
|
||||||
|
// two CredentialPicker components on the same page with same credential type.
|
||||||
|
if (credential.type !== props.credentialType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$emit('credentialSelected', credential.id);
|
||||||
|
},
|
||||||
|
onCredentialDeleted: (deletedCredentialId) => {
|
||||||
|
if (deletedCredentialId !== props.selectedCredentialId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsWoDeleted = credentialOptions.value
|
||||||
|
.map((credential) => credential.id)
|
||||||
|
.filter((id) => id !== deletedCredentialId);
|
||||||
|
if (optionsWoDeleted.length > 0) {
|
||||||
|
$emit('credentialSelected', optionsWoDeleted[0]);
|
||||||
|
} else {
|
||||||
|
$emit('credentialDeselected');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="credentialOptions.length > 0" :class="$style.dropdown">
|
||||||
|
<CredentialsDropdown
|
||||||
|
:credential-type="props.credentialType"
|
||||||
|
:credential-options="credentialOptions"
|
||||||
|
:selected-credential-id="props.selectedCredentialId"
|
||||||
|
@credential-selected="onCredentialSelected"
|
||||||
|
@new-credential="createNewCredential"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<n8n-icon-button
|
||||||
|
icon="pen"
|
||||||
|
type="secondary"
|
||||||
|
:class="{
|
||||||
|
[$style.edit]: true,
|
||||||
|
[$style.invisible]: !props.selectedCredentialId,
|
||||||
|
}"
|
||||||
|
:title="i18n.baseText('nodeCredentials.updateCredential')"
|
||||||
|
@click="editCredential()"
|
||||||
|
data-test-id="credential-edit-button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n8n-button
|
||||||
|
v-else
|
||||||
|
:label="`Create new ${props.appName} credential`"
|
||||||
|
@click="createNewCredential"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.dropdown {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 20px;
|
||||||
|
margin-left: var(--spacing-2xs);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invisible {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,73 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
import { useI18n } from '@/composables';
|
||||||
|
|
||||||
|
export type CredentialOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
typeDisplayName: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
credentialOptions: {
|
||||||
|
type: Array as PropType<CredentialOption[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
selectedCredentialId: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const $emit = defineEmits({
|
||||||
|
credentialSelected: (_credentialId: string) => true,
|
||||||
|
newCredential: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const NEW_CREDENTIALS_TEXT = `- ${i18n.baseText('nodeCredentials.createNew')} -`;
|
||||||
|
|
||||||
|
const onCredentialSelected = (credentialId: string) => {
|
||||||
|
if (credentialId === NEW_CREDENTIALS_TEXT) {
|
||||||
|
$emit('newCredential');
|
||||||
|
} else {
|
||||||
|
$emit('credentialSelected', credentialId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n8n-select
|
||||||
|
size="small"
|
||||||
|
:modelValue="props.selectedCredentialId"
|
||||||
|
@update:modelValue="onCredentialSelected"
|
||||||
|
>
|
||||||
|
<n8n-option
|
||||||
|
v-for="item in props.credentialOptions"
|
||||||
|
:data-test-id="`node-credentials-select-item-${item.id}`"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.name"
|
||||||
|
:value="item.id"
|
||||||
|
>
|
||||||
|
<div :class="[$style.credentialOption, 'mt-2xs mb-2xs']">
|
||||||
|
<n8n-text bold>{{ item.name }}</n8n-text>
|
||||||
|
<n8n-text size="small">{{ item.typeDisplayName }}</n8n-text>
|
||||||
|
</div>
|
||||||
|
</n8n-option>
|
||||||
|
<n8n-option
|
||||||
|
data-test-id="node-credentials-select-item-new"
|
||||||
|
:key="NEW_CREDENTIALS_TEXT"
|
||||||
|
:value="NEW_CREDENTIALS_TEXT"
|
||||||
|
:label="NEW_CREDENTIALS_TEXT"
|
||||||
|
>
|
||||||
|
</n8n-option>
|
||||||
|
</n8n-select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.credentialOption {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -206,9 +206,7 @@ export default defineComponent({
|
||||||
methods: {
|
methods: {
|
||||||
async initView(loadWorkflow: boolean): Promise<void> {
|
async initView(loadWorkflow: boolean): Promise<void> {
|
||||||
if (loadWorkflow) {
|
if (loadWorkflow) {
|
||||||
if (this.nodeTypesStore.allNodeTypes.length === 0) {
|
await this.nodeTypesStore.loadNodeTypesIfNotLoaded();
|
||||||
await this.nodeTypesStore.getNodeTypes();
|
|
||||||
}
|
|
||||||
await this.openWorkflow(this.$route.params.name);
|
await this.openWorkflow(this.$route.params.name);
|
||||||
this.uiStore.nodeViewInitialized = false;
|
this.uiStore.nodeViewInitialized = false;
|
||||||
if (this.workflowsStore.currentWorkflowExecutions.length === 0) {
|
if (this.workflowsStore.currentWorkflowExecutions.length === 0) {
|
||||||
|
|
|
@ -401,6 +401,7 @@ export const enum VIEWS {
|
||||||
EXECUTION_DEBUG = 'ExecutionDebug',
|
EXECUTION_DEBUG = 'ExecutionDebug',
|
||||||
EXECUTION_HOME = 'ExecutionsLandingPage',
|
EXECUTION_HOME = 'ExecutionsLandingPage',
|
||||||
TEMPLATE = 'TemplatesWorkflowView',
|
TEMPLATE = 'TemplatesWorkflowView',
|
||||||
|
TEMPLATE_SETUP = 'TemplatesWorkflowSetupView',
|
||||||
TEMPLATES = 'TemplatesSearchView',
|
TEMPLATES = 'TemplatesSearchView',
|
||||||
CREDENTIALS = 'CredentialsView',
|
CREDENTIALS = 'CredentialsView',
|
||||||
VARIABLES = 'VariablesView',
|
VARIABLES = 'VariablesView',
|
||||||
|
|
|
@ -63,6 +63,7 @@
|
||||||
"generic.editor": "Editor",
|
"generic.editor": "Editor",
|
||||||
"generic.seePlans": "See plans",
|
"generic.seePlans": "See plans",
|
||||||
"generic.loading": "Loading",
|
"generic.loading": "Loading",
|
||||||
|
"generic.and": "and",
|
||||||
"about.aboutN8n": "About n8n",
|
"about.aboutN8n": "About n8n",
|
||||||
"about.close": "Close",
|
"about.close": "Close",
|
||||||
"about.license": "License",
|
"about.license": "License",
|
||||||
|
@ -2271,5 +2272,11 @@
|
||||||
"executionUsage.label.executions": "Executions",
|
"executionUsage.label.executions": "Executions",
|
||||||
"executionUsage.button.upgrade": "Upgrade plan",
|
"executionUsage.button.upgrade": "Upgrade plan",
|
||||||
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
|
"executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.",
|
||||||
"executionUsage.ranOutOfExecutions.text": "You’re out of executions. Upgrade your plan to keep automating."
|
"executionUsage.ranOutOfExecutions.text": "You’re out of executions. Upgrade your plan to keep automating.",
|
||||||
|
"templateSetup.title": "Setup '{name}' template",
|
||||||
|
"templateSetup.instructions": "You need {0} account to setup this template",
|
||||||
|
"templateSetup.skip": "Skip",
|
||||||
|
"templateSetup.continue.button": "Continue",
|
||||||
|
"templateSetup.continue.tooltip": "Connect to {numLeft} more app to continue | Connect to {numLeft} more apps to continue",
|
||||||
|
"templateSetup.credential.description": "The credential you select will be used in the {0} node of the workflow template. | The credential you select will be used in the {0} nodes of the workflow template."
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,6 +42,8 @@ const SigninView = async () => import('./views/SigninView.vue');
|
||||||
const SignupView = async () => import('./views/SignupView.vue');
|
const SignupView = async () => import('./views/SignupView.vue');
|
||||||
const TemplatesCollectionView = async () => import('@/views/TemplatesCollectionView.vue');
|
const TemplatesCollectionView = async () => import('@/views/TemplatesCollectionView.vue');
|
||||||
const TemplatesWorkflowView = async () => import('@/views/TemplatesWorkflowView.vue');
|
const TemplatesWorkflowView = async () => import('@/views/TemplatesWorkflowView.vue');
|
||||||
|
const SetupWorkflowFromTemplateView = async () =>
|
||||||
|
import('@/views/SetupWorkflowFromTemplateView/SetupWorkflowFromTemplateView.vue');
|
||||||
const TemplatesSearchView = async () => import('@/views/TemplatesSearchView.vue');
|
const TemplatesSearchView = async () => import('@/views/TemplatesSearchView.vue');
|
||||||
const CredentialsView = async () => import('@/views/CredentialsView.vue');
|
const CredentialsView = async () => import('@/views/CredentialsView.vue');
|
||||||
const ExecutionsView = async () => import('@/views/ExecutionsView.vue');
|
const ExecutionsView = async () => import('@/views/ExecutionsView.vue');
|
||||||
|
@ -123,6 +125,28 @@ export const routes = [
|
||||||
middleware: ['authenticated'],
|
middleware: ['authenticated'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/templates/:id/setup',
|
||||||
|
name: VIEWS.TEMPLATE_SETUP,
|
||||||
|
components: {
|
||||||
|
default: SetupWorkflowFromTemplateView,
|
||||||
|
sidebar: MainSidebar,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
templatesEnabled: true,
|
||||||
|
getRedirect: getTemplatesRedirect,
|
||||||
|
telemetry: {
|
||||||
|
getProperties(route: RouteLocation) {
|
||||||
|
const templatesStore = useTemplatesStore();
|
||||||
|
return {
|
||||||
|
template_id: route.params.id,
|
||||||
|
wf_template_repo_session_id: templatesStore.currentSessionId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
middleware: ['authenticated'],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/templates/',
|
path: '/templates/',
|
||||||
name: VIEWS.TEMPLATES,
|
name: VIEWS.TEMPLATES,
|
||||||
|
|
|
@ -41,6 +41,8 @@ const DEFAULT_CREDENTIAL_NAME = 'Unnamed credential';
|
||||||
const DEFAULT_CREDENTIAL_POSTFIX = 'account';
|
const DEFAULT_CREDENTIAL_POSTFIX = 'account';
|
||||||
const TYPES_WITH_DEFAULT_NAME = ['httpBasicAuth', 'oAuth2Api', 'httpDigestAuth', 'oAuth1Api'];
|
const TYPES_WITH_DEFAULT_NAME = ['httpBasicAuth', 'oAuth2Api', 'httpDigestAuth', 'oAuth1Api'];
|
||||||
|
|
||||||
|
export type CredentialsStore = ReturnType<typeof useCredentialsStore>;
|
||||||
|
|
||||||
export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
||||||
state: (): ICredentialsState => ({
|
state: (): ICredentialsState => ({
|
||||||
credentialTypes: {},
|
credentialTypes: {},
|
||||||
|
@ -400,3 +402,42 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function for listening to credential changes in the store
|
||||||
|
*/
|
||||||
|
export const listenForCredentialChanges = (opts: {
|
||||||
|
store: CredentialsStore;
|
||||||
|
onCredentialCreated?: (credential: ICredentialsResponse) => void;
|
||||||
|
onCredentialUpdated?: (credential: ICredentialsResponse) => void;
|
||||||
|
onCredentialDeleted?: (credentialId: string) => void;
|
||||||
|
}): void => {
|
||||||
|
const { store, onCredentialCreated, onCredentialDeleted, onCredentialUpdated } = opts;
|
||||||
|
const listeningForActions = ['createNewCredential', 'updateCredential', 'deleteCredential'];
|
||||||
|
|
||||||
|
store.$onAction((result) => {
|
||||||
|
const { name, after, args } = result;
|
||||||
|
after(async (returnValue) => {
|
||||||
|
if (!listeningForActions.includes(name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'createNewCredential':
|
||||||
|
const createdCredential = returnValue as ICredentialsResponse;
|
||||||
|
onCredentialCreated?.(createdCredential);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'updateCredential':
|
||||||
|
const updatedCredential = returnValue as ICredentialsResponse;
|
||||||
|
onCredentialUpdated?.(updatedCredential);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'deleteCredential':
|
||||||
|
const credentialId = args[0].id;
|
||||||
|
onCredentialDeleted?.(credentialId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
@ -263,6 +263,14 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
|
||||||
this.setNodeTypes(nodeTypes);
|
this.setNodeTypes(nodeTypes);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Loads node types if they haven't been loaded yet
|
||||||
|
*/
|
||||||
|
async loadNodeTypesIfNotLoaded(): Promise<void> {
|
||||||
|
if (Object.keys(this.nodeTypes).length === 0) {
|
||||||
|
await this.getNodeTypes();
|
||||||
|
}
|
||||||
|
},
|
||||||
async getNodeTranslationHeaders(): Promise<void> {
|
async getNodeTranslationHeaders(): Promise<void> {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const headers = await getNodeTranslationHeaders(rootStore.getRestApiContext);
|
const headers = await getNodeTranslationHeaders(rootStore.getRestApiContext);
|
||||||
|
|
|
@ -47,6 +47,12 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
|
||||||
getTemplateById() {
|
getTemplateById() {
|
||||||
return (id: string): null | ITemplatesWorkflow => this.workflows[id];
|
return (id: string): null | ITemplatesWorkflow => this.workflows[id];
|
||||||
},
|
},
|
||||||
|
getFullTemplateById() {
|
||||||
|
return (id: string): null | ITemplatesWorkflowFull => {
|
||||||
|
const template = this.workflows[id];
|
||||||
|
return template && 'full' in template && template.full ? template : null;
|
||||||
|
};
|
||||||
|
},
|
||||||
getCollectionById() {
|
getCollectionById() {
|
||||||
return (id: string): null | ITemplatesCollection => this.collections[id];
|
return (id: string): null | ITemplatesCollection => this.collections[id];
|
||||||
},
|
},
|
||||||
|
|
8
packages/editor-ui/src/utils/assert.ts
Normal file
8
packages/editor-ui/src/utils/assert.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
/**
|
||||||
|
* Asserts given condition
|
||||||
|
*/
|
||||||
|
export function assert(condition: unknown, message?: string): asserts condition {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(message ?? 'Assertion failed');
|
||||||
|
}
|
||||||
|
}
|
16
packages/editor-ui/src/utils/featureFlag.ts
Normal file
16
packages/editor-ui/src/utils/featureFlag.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// Feature flags
|
||||||
|
export const enum FeatureFlag {
|
||||||
|
templateCredentialsSetup = 'template-credentials-setup',
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLocaleStorageKey = (key: string): boolean => {
|
||||||
|
try {
|
||||||
|
// Local storage might not be available in all envs e.g. when user has
|
||||||
|
// disabled it in their browser
|
||||||
|
return !!localStorage.getItem(key);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isFeatureFlagEnabled = (flag: FeatureFlag): boolean => hasLocaleStorageKey(flag);
|
33
packages/editor-ui/src/utils/formatters/listFormatter.ts
Normal file
33
packages/editor-ui/src/utils/formatters/listFormatter.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import type { I18nClass } from '@/plugins/i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a list of items into a string. Each item is formatted using
|
||||||
|
* the given function and the are separated by a comma except for the last
|
||||||
|
* item which is separated by "and".
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatList(['a', 'b', 'c'], {
|
||||||
|
* formatFn: (x) => `"${x}"`
|
||||||
|
* i18n
|
||||||
|
* });
|
||||||
|
* // => '"a", "b" and "c"'
|
||||||
|
*/
|
||||||
|
export const formatList = <T>(
|
||||||
|
list: T[],
|
||||||
|
opts: {
|
||||||
|
formatFn: (item: T) => string;
|
||||||
|
i18n: I18nClass;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { i18n, formatFn } = opts;
|
||||||
|
if (list.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (list.length === 1) {
|
||||||
|
return formatFn(list[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allButLast = list.slice(0, -1);
|
||||||
|
const last = list[list.length - 1];
|
||||||
|
return `${allButLast.map(formatFn).join(', ')} ${i18n.baseText('generic.and')} ${formatFn(last)}`;
|
||||||
|
};
|
|
@ -7,7 +7,6 @@ import { N8nConnector } from '@/plugins/connectors/N8nCustomConnector';
|
||||||
import type {
|
import type {
|
||||||
ConnectionTypes,
|
ConnectionTypes,
|
||||||
IConnection,
|
IConnection,
|
||||||
INode,
|
|
||||||
ITaskData,
|
ITaskData,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
NodeInputConnections,
|
NodeInputConnections,
|
||||||
|
@ -336,7 +335,7 @@ export const addOverlays = (connection: Connection, overlays: OverlaySpec[]) =>
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLeftmostTopNode = (nodes: INodeUi[]): INodeUi => {
|
export const getLeftmostTopNode = <T extends { position: XYPosition }>(nodes: T[]): T => {
|
||||||
return nodes.reduce((leftmostTop, node) => {
|
return nodes.reduce((leftmostTop, node) => {
|
||||||
if (node.position[0] > leftmostTop.position[0] || node.position[1] > leftmostTop.position[1]) {
|
if (node.position[0] > leftmostTop.position[0] || node.position[1] > leftmostTop.position[1]) {
|
||||||
return leftmostTop;
|
return leftmostTop;
|
||||||
|
@ -963,7 +962,7 @@ export const getInputEndpointUUID = (
|
||||||
return `${nodeId}${INPUT_UUID_KEY}${getScope(connectionType) || ''}${inputIndex}`;
|
return `${nodeId}${INPUT_UUID_KEY}${getScope(connectionType) || ''}${inputIndex}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFixedNodesList = (workflowNodes: INode[]) => {
|
export const getFixedNodesList = <T extends { position: XYPosition }>(workflowNodes: T[]): T[] => {
|
||||||
const nodes = [...workflowNodes];
|
const nodes = [...workflowNodes];
|
||||||
|
|
||||||
if (nodes.length) {
|
if (nodes.length) {
|
||||||
|
|
37
packages/editor-ui/src/utils/templates/templateActions.ts
Normal file
37
packages/editor-ui/src/utils/templates/templateActions.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import type { INodeUi, IWorkflowData, IWorkflowTemplate } from '@/Interface';
|
||||||
|
import { getNewWorkflow } from '@/api/workflows';
|
||||||
|
import type { useRootStore } from '@/stores/n8nRoot.store';
|
||||||
|
import type { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { getFixedNodesList } from '@/utils/nodeViewUtils';
|
||||||
|
import { replaceAllTemplateNodeCredentials } from '@/utils/templates/templateTransforms';
|
||||||
|
import type { INodeCredentialsDetails } from 'n8n-workflow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new workflow from a template
|
||||||
|
*/
|
||||||
|
export async function createWorkflowFromTemplate(
|
||||||
|
template: IWorkflowTemplate,
|
||||||
|
credentialOverrides: Record<string, INodeCredentialsDetails>,
|
||||||
|
rootStore: ReturnType<typeof useRootStore>,
|
||||||
|
workflowsStore: ReturnType<typeof useWorkflowsStore>,
|
||||||
|
) {
|
||||||
|
const workflowData = await getNewWorkflow(rootStore.getRestApiContext, template.name);
|
||||||
|
const nodesWithCreds = replaceAllTemplateNodeCredentials(
|
||||||
|
template.workflow.nodes,
|
||||||
|
credentialOverrides,
|
||||||
|
);
|
||||||
|
const nodes = getFixedNodesList(nodesWithCreds) as INodeUi[];
|
||||||
|
const connections = template.workflow.connections;
|
||||||
|
|
||||||
|
const workflowToCreate: IWorkflowData = {
|
||||||
|
name: workflowData.name,
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
active: false,
|
||||||
|
// Ignored: pinData, settings, tags, versionId, meta
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdWorkflow = await workflowsStore.createNewWorkflow(workflowToCreate);
|
||||||
|
|
||||||
|
return createdWorkflow;
|
||||||
|
}
|
81
packages/editor-ui/src/utils/templates/templateTransforms.ts
Normal file
81
packages/editor-ui/src/utils/templates/templateTransforms.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import type { IWorkflowTemplateNode, IWorkflowTemplateNodeCredentials } from '@/Interface';
|
||||||
|
import type { NormalizedTemplateNodeCredentials } from '@/utils/templates/templateTypes';
|
||||||
|
import type { INodeCredentials, INodeCredentialsDetails } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export type IWorkflowTemplateNodeWithCredentials = IWorkflowTemplateNode &
|
||||||
|
Required<Pick<IWorkflowTemplateNode, 'credentials'>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a template workflow node has credentials defined
|
||||||
|
*/
|
||||||
|
export const hasNodeCredentials = (
|
||||||
|
node: IWorkflowTemplateNode,
|
||||||
|
): node is IWorkflowTemplateNodeWithCredentials =>
|
||||||
|
!!node.credentials && Object.keys(node.credentials).length > 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the credentials of a template node. Templates created with
|
||||||
|
* different versions of n8n may have different credential formats.
|
||||||
|
*/
|
||||||
|
export const normalizeTemplateNodeCredentials = (
|
||||||
|
credentials: IWorkflowTemplateNodeCredentials,
|
||||||
|
): NormalizedTemplateNodeCredentials => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(credentials).map(([key, value]) => {
|
||||||
|
return typeof value === 'string' ? [key, value] : [key, value.name];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the credentials of a node with the given replacements
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const nodeCredentials = { twitterOAuth1Api: "twitter" };
|
||||||
|
* const toReplaceByType = { twitter: {
|
||||||
|
* id: "BrEOZ5Cje6VYh9Pc",
|
||||||
|
* name: "X OAuth account"
|
||||||
|
* }};
|
||||||
|
* replaceTemplateNodeCredentials(nodeCredentials, toReplaceByType);
|
||||||
|
* // => { twitterOAuth1Api: { id: "BrEOZ5Cje6VYh9Pc", name: "X OAuth account" } }
|
||||||
|
*/
|
||||||
|
export const replaceTemplateNodeCredentials = (
|
||||||
|
nodeCredentials: IWorkflowTemplateNodeCredentials,
|
||||||
|
toReplaceByName: Record<string, INodeCredentialsDetails>,
|
||||||
|
) => {
|
||||||
|
if (!nodeCredentials) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newNodeCredentials: INodeCredentials = {};
|
||||||
|
const normalizedCredentials = normalizeTemplateNodeCredentials(nodeCredentials);
|
||||||
|
for (const credentialType in normalizedCredentials) {
|
||||||
|
const credentialNameInTemplate = normalizedCredentials[credentialType];
|
||||||
|
const toReplaceWith = toReplaceByName[credentialNameInTemplate];
|
||||||
|
if (toReplaceWith) {
|
||||||
|
newNodeCredentials[credentialType] = toReplaceWith;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newNodeCredentials;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the credentials of all template workflow nodes with the given
|
||||||
|
* replacements
|
||||||
|
*/
|
||||||
|
export const replaceAllTemplateNodeCredentials = (
|
||||||
|
nodes: IWorkflowTemplateNode[],
|
||||||
|
toReplaceWith: Record<string, INodeCredentialsDetails>,
|
||||||
|
) => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (hasNodeCredentials(node)) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
credentials: replaceTemplateNodeCredentials(node.credentials, toReplaceWith),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
9
packages/editor-ui/src/utils/templates/templateTypes.ts
Normal file
9
packages/editor-ui/src/utils/templates/templateTypes.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* The credentials of a node in a template workflow. Map from credential
|
||||||
|
* type name to credential name.
|
||||||
|
* @example
|
||||||
|
* {
|
||||||
|
* twitterOAuth1Api: "Twitter credentials"
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export type NormalizedTemplateNodeCredentials = Record<string, string>;
|
|
@ -110,12 +110,9 @@ export default defineComponent({
|
||||||
this.credentialsStore.fetchAllCredentials(),
|
this.credentialsStore.fetchAllCredentials(),
|
||||||
this.credentialsStore.fetchCredentialTypes(false),
|
this.credentialsStore.fetchCredentialTypes(false),
|
||||||
this.externalSecretsStore.fetchAllSecrets(),
|
this.externalSecretsStore.fetchAllSecrets(),
|
||||||
|
this.nodeTypesStore.loadNodeTypesIfNotLoaded(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (this.nodeTypesStore.allNodeTypes.length === 0) {
|
|
||||||
loadPromises.push(this.nodeTypesStore.getNodeTypes());
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(loadPromises);
|
await Promise.all(loadPromises);
|
||||||
|
|
||||||
await this.usersStore.fetchUsers(); // Can be loaded in the background, used for filtering
|
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"
|
v-if="template"
|
||||||
:label="$locale.baseText('template.buttons.useThisWorkflowButton')"
|
:label="$locale.baseText('template.buttons.useThisWorkflowButton')"
|
||||||
size="large"
|
size="large"
|
||||||
@click="openWorkflow(template.id, $event)"
|
@click="openTemplateSetup(template.id, $event)"
|
||||||
/>
|
/>
|
||||||
<n8n-loading :loading="!template" :rows="1" variant="button" />
|
<n8n-loading :loading="!template" :rows="1" variant="button" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -68,6 +68,7 @@ import { setPageTitle } from '@/utils';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import { usePostHog } from '@/stores/posthog.store';
|
import { usePostHog } from '@/stores/posthog.store';
|
||||||
|
import { FeatureFlag, isFeatureFlagEnabled } from '@/utils/featureFlag';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'TemplatesWorkflowView',
|
name: 'TemplatesWorkflowView',
|
||||||
|
@ -94,7 +95,7 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openWorkflow(id: string, e: PointerEvent) {
|
openTemplateSetup(id: string, e: PointerEvent) {
|
||||||
const telemetryPayload = {
|
const telemetryPayload = {
|
||||||
source: 'workflow',
|
source: 'workflow',
|
||||||
template_id: id,
|
template_id: id,
|
||||||
|
@ -105,12 +106,23 @@ export default defineComponent({
|
||||||
this.$telemetry.track('User inserted workflow template', telemetryPayload, {
|
this.$telemetry.track('User inserted workflow template', telemetryPayload, {
|
||||||
withPostHog: true,
|
withPostHog: true,
|
||||||
});
|
});
|
||||||
if (e.metaKey || e.ctrlKey) {
|
|
||||||
const route = this.$router.resolve({ name: VIEWS.TEMPLATE_IMPORT, params: { id } });
|
if (isFeatureFlagEnabled(FeatureFlag.templateCredentialsSetup)) {
|
||||||
window.open(route.href, '_blank');
|
if (e.metaKey || e.ctrlKey) {
|
||||||
return;
|
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 {
|
} 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() {
|
onHidePreview() {
|
||||||
|
|
Loading…
Reference in a new issue