mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 12:44:07 -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,
|
||||
INodeProperties,
|
||||
NodeConnectionType,
|
||||
INodeCredentialsDetails,
|
||||
} from 'n8n-workflow';
|
||||
import type { BulkCommand, Undoable } from '@/models/history';
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
workflow: Pick<IWorkflowData, 'nodes' | 'connections' | 'settings' | 'pinData'>;
|
||||
workflow: Pick<IWorkflowData, 'connections' | 'settings' | 'pinData'> & {
|
||||
nodes: IWorkflowTemplateNode[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface INewWorkflowData {
|
||||
|
@ -790,6 +803,9 @@ export interface ITemplatesCollectionResponse extends ITemplatesCollectionExtend
|
|||
workflows: ITemplatesWorkflow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A template without the actual workflow definition
|
||||
*/
|
||||
export interface ITemplatesWorkflow {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
|
@ -807,6 +823,9 @@ export interface ITemplatesWorkflowResponse extends ITemplatesWorkflow, IWorkflo
|
|||
categories: ITemplatesCategory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A template with also the full workflow definition
|
||||
*/
|
||||
export interface ITemplatesWorkflowFull extends ITemplatesWorkflowResponse {
|
||||
full: true;
|
||||
}
|
||||
|
@ -1302,7 +1321,7 @@ export interface INodeTypesState {
|
|||
export interface ITemplateState {
|
||||
categories: { [id: string]: ITemplatesCategory };
|
||||
collections: { [id: string]: ITemplatesCollection };
|
||||
workflows: { [id: string]: ITemplatesWorkflow };
|
||||
workflows: { [id: string]: ITemplatesWorkflow | ITemplatesWorkflowFull };
|
||||
workflowSearches: {
|
||||
[search: 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: {
|
||||
async initView(loadWorkflow: boolean): Promise<void> {
|
||||
if (loadWorkflow) {
|
||||
if (this.nodeTypesStore.allNodeTypes.length === 0) {
|
||||
await this.nodeTypesStore.getNodeTypes();
|
||||
}
|
||||
await this.nodeTypesStore.loadNodeTypesIfNotLoaded();
|
||||
await this.openWorkflow(this.$route.params.name);
|
||||
this.uiStore.nodeViewInitialized = false;
|
||||
if (this.workflowsStore.currentWorkflowExecutions.length === 0) {
|
||||
|
|
|
@ -401,6 +401,7 @@ export const enum VIEWS {
|
|||
EXECUTION_DEBUG = 'ExecutionDebug',
|
||||
EXECUTION_HOME = 'ExecutionsLandingPage',
|
||||
TEMPLATE = 'TemplatesWorkflowView',
|
||||
TEMPLATE_SETUP = 'TemplatesWorkflowSetupView',
|
||||
TEMPLATES = 'TemplatesSearchView',
|
||||
CREDENTIALS = 'CredentialsView',
|
||||
VARIABLES = 'VariablesView',
|
||||
|
|
|
@ -63,6 +63,7 @@
|
|||
"generic.editor": "Editor",
|
||||
"generic.seePlans": "See plans",
|
||||
"generic.loading": "Loading",
|
||||
"generic.and": "and",
|
||||
"about.aboutN8n": "About n8n",
|
||||
"about.close": "Close",
|
||||
"about.license": "License",
|
||||
|
@ -2271,5 +2272,11 @@
|
|||
"executionUsage.label.executions": "Executions",
|
||||
"executionUsage.button.upgrade": "Upgrade plan",
|
||||
"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 TemplatesCollectionView = async () => import('@/views/TemplatesCollectionView.vue');
|
||||
const TemplatesWorkflowView = async () => import('@/views/TemplatesWorkflowView.vue');
|
||||
const SetupWorkflowFromTemplateView = async () =>
|
||||
import('@/views/SetupWorkflowFromTemplateView/SetupWorkflowFromTemplateView.vue');
|
||||
const TemplatesSearchView = async () => import('@/views/TemplatesSearchView.vue');
|
||||
const CredentialsView = async () => import('@/views/CredentialsView.vue');
|
||||
const ExecutionsView = async () => import('@/views/ExecutionsView.vue');
|
||||
|
@ -123,6 +125,28 @@ export const routes = [
|
|||
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/',
|
||||
name: VIEWS.TEMPLATES,
|
||||
|
|
|
@ -41,6 +41,8 @@ const DEFAULT_CREDENTIAL_NAME = 'Unnamed credential';
|
|||
const DEFAULT_CREDENTIAL_POSTFIX = 'account';
|
||||
const TYPES_WITH_DEFAULT_NAME = ['httpBasicAuth', 'oAuth2Api', 'httpDigestAuth', 'oAuth1Api'];
|
||||
|
||||
export type CredentialsStore = ReturnType<typeof useCredentialsStore>;
|
||||
|
||||
export const useCredentialsStore = defineStore(STORES.CREDENTIALS, {
|
||||
state: (): ICredentialsState => ({
|
||||
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);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 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> {
|
||||
const rootStore = useRootStore();
|
||||
const headers = await getNodeTranslationHeaders(rootStore.getRestApiContext);
|
||||
|
|
|
@ -47,6 +47,12 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, {
|
|||
getTemplateById() {
|
||||
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() {
|
||||
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 {
|
||||
ConnectionTypes,
|
||||
IConnection,
|
||||
INode,
|
||||
ITaskData,
|
||||
INodeExecutionData,
|
||||
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) => {
|
||||
if (node.position[0] > leftmostTop.position[0] || node.position[1] > leftmostTop.position[1]) {
|
||||
return leftmostTop;
|
||||
|
@ -963,7 +962,7 @@ export const getInputEndpointUUID = (
|
|||
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];
|
||||
|
||||
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.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() {
|
||||
|
|
Loading…
Reference in a new issue