diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index c5a46d55d8..028f7b8af5 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -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 { + // 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; + workflow: Pick & { + 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[]; diff --git a/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.vue b/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.vue new file mode 100644 index 0000000000..31256fc73d --- /dev/null +++ b/packages/editor-ui/src/components/CredentialPicker/CredentialPicker.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/packages/editor-ui/src/components/CredentialPicker/CredentialsDropdown.vue b/packages/editor-ui/src/components/CredentialPicker/CredentialsDropdown.vue new file mode 100644 index 0000000000..026dcc43c6 --- /dev/null +++ b/packages/editor-ui/src/components/CredentialPicker/CredentialsDropdown.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue index 4f0763b648..a75f74fe7e 100644 --- a/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue +++ b/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue @@ -206,9 +206,7 @@ export default defineComponent({ methods: { async initView(loadWorkflow: boolean): Promise { 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) { diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index c17c56cecc..b3d75a44c4 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -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', diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index d3880239c4..b3dde531fc 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -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." } diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 44008d7922..6629f45e0d 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -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, diff --git a/packages/editor-ui/src/stores/credentials.store.ts b/packages/editor-ui/src/stores/credentials.store.ts index c7aeaa5d26..006823081d 100644 --- a/packages/editor-ui/src/stores/credentials.store.ts +++ b/packages/editor-ui/src/stores/credentials.store.ts @@ -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; + 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; + } + }); + }); +}; diff --git a/packages/editor-ui/src/stores/nodeTypes.store.ts b/packages/editor-ui/src/stores/nodeTypes.store.ts index 2659b5c98e..510fc45cee 100644 --- a/packages/editor-ui/src/stores/nodeTypes.store.ts +++ b/packages/editor-ui/src/stores/nodeTypes.store.ts @@ -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 { + if (Object.keys(this.nodeTypes).length === 0) { + await this.getNodeTypes(); + } + }, async getNodeTranslationHeaders(): Promise { const rootStore = useRootStore(); const headers = await getNodeTranslationHeaders(rootStore.getRestApiContext); diff --git a/packages/editor-ui/src/stores/templates.store.ts b/packages/editor-ui/src/stores/templates.store.ts index ab80091fdf..911593f1c9 100644 --- a/packages/editor-ui/src/stores/templates.store.ts +++ b/packages/editor-ui/src/stores/templates.store.ts @@ -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]; }, diff --git a/packages/editor-ui/src/utils/assert.ts b/packages/editor-ui/src/utils/assert.ts new file mode 100644 index 0000000000..4366324141 --- /dev/null +++ b/packages/editor-ui/src/utils/assert.ts @@ -0,0 +1,8 @@ +/** + * Asserts given condition + */ +export function assert(condition: unknown, message?: string): asserts condition { + if (!condition) { + throw new Error(message ?? 'Assertion failed'); + } +} diff --git a/packages/editor-ui/src/utils/featureFlag.ts b/packages/editor-ui/src/utils/featureFlag.ts new file mode 100644 index 0000000000..989496bfd4 --- /dev/null +++ b/packages/editor-ui/src/utils/featureFlag.ts @@ -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); diff --git a/packages/editor-ui/src/utils/formatters/listFormatter.ts b/packages/editor-ui/src/utils/formatters/listFormatter.ts new file mode 100644 index 0000000000..bc6619fe46 --- /dev/null +++ b/packages/editor-ui/src/utils/formatters/listFormatter.ts @@ -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 = ( + 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)}`; +}; diff --git a/packages/editor-ui/src/utils/nodeViewUtils.ts b/packages/editor-ui/src/utils/nodeViewUtils.ts index 3edcf2fe64..6d9f97127d 100644 --- a/packages/editor-ui/src/utils/nodeViewUtils.ts +++ b/packages/editor-ui/src/utils/nodeViewUtils.ts @@ -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 = (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 = (workflowNodes: T[]): T[] => { const nodes = [...workflowNodes]; if (nodes.length) { diff --git a/packages/editor-ui/src/utils/templates/templateActions.ts b/packages/editor-ui/src/utils/templates/templateActions.ts new file mode 100644 index 0000000000..16a18ee1d3 --- /dev/null +++ b/packages/editor-ui/src/utils/templates/templateActions.ts @@ -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, + rootStore: ReturnType, + workflowsStore: ReturnType, +) { + 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; +} diff --git a/packages/editor-ui/src/utils/templates/templateTransforms.ts b/packages/editor-ui/src/utils/templates/templateTransforms.ts new file mode 100644 index 0000000000..41266aaacd --- /dev/null +++ b/packages/editor-ui/src/utils/templates/templateTransforms.ts @@ -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>; + +/** + * 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, +) => { + 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, +) => { + return nodes.map((node) => { + if (hasNodeCredentials(node)) { + return { + ...node, + credentials: replaceTemplateNodeCredentials(node.credentials, toReplaceWith), + }; + } + + return node; + }); +}; diff --git a/packages/editor-ui/src/utils/templates/templateTypes.ts b/packages/editor-ui/src/utils/templates/templateTypes.ts new file mode 100644 index 0000000000..97484580db --- /dev/null +++ b/packages/editor-ui/src/utils/templates/templateTypes.ts @@ -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; diff --git a/packages/editor-ui/src/views/CredentialsView.vue b/packages/editor-ui/src/views/CredentialsView.vue index 80d57af398..00acb157da 100644 --- a/packages/editor-ui/src/views/CredentialsView.vue +++ b/packages/editor-ui/src/views/CredentialsView.vue @@ -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 diff --git a/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/AppsRequiringCredsNotice.vue b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/AppsRequiringCredsNotice.vue new file mode 100644 index 0000000000..7173805236 --- /dev/null +++ b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/AppsRequiringCredsNotice.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/IconSuccess.vue b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/IconSuccess.vue new file mode 100644 index 0000000000..59e5c01481 --- /dev/null +++ b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/IconSuccess.vue @@ -0,0 +1,9 @@ + + + diff --git a/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/SetupTemplateFormStep.vue b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/SetupTemplateFormStep.vue new file mode 100644 index 0000000000..5975368750 --- /dev/null +++ b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/SetupTemplateFormStep.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/SetupWorkflowFromTemplateView.vue b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/SetupWorkflowFromTemplateView.vue new file mode 100644 index 0000000000..e10abd9123 --- /dev/null +++ b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/SetupWorkflowFromTemplateView.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/setupTemplate.store.test.ts b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/setupTemplate.store.test.ts new file mode 100644 index 0000000000..64d33f311d --- /dev/null +++ b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/setupTemplate.store.test.ts @@ -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 = (obj: Record) => { + return new Map(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; + + 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 = 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], + }, + ], + }, + ]); + }); + }); +}); diff --git a/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/setupTemplate.store.ts b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/setupTemplate.store.ts new file mode 100644 index 0000000000..ffbdff50a5 --- /dev/null +++ b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/setupTemplate.store.ts @@ -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(); + + 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(); + + 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, + getAppNameByNodeType: (nodeTypeName: string, version?: number) => string, +) => { + const credentialsByAppName = new Map(); + + 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(''); + 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 + >({}); + + //#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 = {}; + + 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, + }; +}); diff --git a/packages/editor-ui/src/views/TemplatesWorkflowView.vue b/packages/editor-ui/src/views/TemplatesWorkflowView.vue index d2761f36e8..828ec84efc 100644 --- a/packages/editor-ui/src/views/TemplatesWorkflowView.vue +++ b/packages/editor-ui/src/views/TemplatesWorkflowView.vue @@ -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)" /> @@ -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() {