feat: Enable cred setup for workflows created from templates (no-changelog) (#8240)

## Summary

Enable users to open credential setup for workflows that have been
created from templates if they skip it.

Next steps (will be their own PRs):
- Add telemetry events
- Add e2e test
- Hide the button when user sets up all the credentials
- Change the feature flag to a new one

## Related tickets and issues

https://linear.app/n8n/issue/ADO-1637/feature-support-template-credential-setup-for-http-request-nodes-that
This commit is contained in:
Tomi Turtiainen
2024-01-05 18:07:57 +02:00
committed by GitHub
parent df5d07bcb8
commit 3cf6704dbb
22 changed files with 858 additions and 560 deletions

View File

@@ -96,6 +96,11 @@
@stopExecution="stopExecution"
@saveKeyboardShortcut="onSaveKeyboardShortcut"
/>
<Suspense>
<div :class="$style.setupCredentialsButtonWrapper">
<SetupWorkflowCredentialsButton />
</div>
</Suspense>
<Suspense>
<NodeCreation
v-if="!isReadOnlyRoute && !readOnlyEnv"
@@ -381,6 +386,10 @@ interface AddNodeOptions {
const NodeCreation = defineAsyncComponent(async () => import('@/components/Node/NodeCreation.vue'));
const CanvasControls = defineAsyncComponent(async () => import('@/components/CanvasControls.vue'));
const SetupWorkflowCredentialsButton = defineAsyncComponent(
async () =>
import('@/components/SetupWorkflowCredentialsButton/SetupWorkflowCredentialsButton.vue'),
);
export default defineComponent({
name: 'NodeView',
@@ -393,6 +402,7 @@ export default defineComponent({
NodeCreation,
CanvasControls,
ContextMenu,
SetupWorkflowCredentialsButton,
},
mixins: [moveNodeWorkflow, workflowHelpers, workflowRun, debounceHelper],
async beforeRouteLeave(to, from, next) {
@@ -5180,4 +5190,10 @@ export default defineComponent({
transform: translate3d(4px, 0, 0);
}
}
.setupCredentialsButtonWrapper {
position: absolute;
left: 35px;
top: var(--spacing-s);
}
</style>

View File

@@ -1,20 +1,24 @@
<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/useI18n';
import type {
AppCredentials,
BaseNode,
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
const i18n = useI18n();
const store = useSetupTemplateStore();
const { appCredentials } = storeToRefs(store);
const formatApp = (app: AppCredentials) => `<b>${app.credentials.length}x ${app.appName}</b>`;
const props = defineProps<{
appCredentials: Array<AppCredentials<BaseNode>>;
}>();
const formatApp = (app: AppCredentials<BaseNode>) =>
`<b>${app.credentials.length}x ${app.appName}</b>`;
const appNodeCounts = computed(() => {
return formatList(appCredentials.value, {
return formatList(props.appCredentials, {
formatFn: formatApp,
i18n,
});

View File

@@ -8,11 +8,13 @@ import IconSuccess from './IconSuccess.vue';
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
import { formatList } from '@/utils/formatters/listFormatter';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import type { IWorkflowTemplateNode } from '@/Interface';
import type {
BaseNode,
CredentialUsages,
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
// Props
const props = defineProps({
@@ -24,10 +26,22 @@ const props = defineProps({
type: Object as PropType<CredentialUsages>,
required: true,
},
selectedCredentialId: {
type: String,
required: false,
default: null,
},
});
const emit = defineEmits<{
(
e: 'credentialSelected',
event: { credentialUsageKey: TemplateCredentialKey; credentialId: string },
): void;
(e: 'credentialDeselected', event: { credentialUsageKey: TemplateCredentialKey }): void;
}>();
// Stores
const setupTemplateStore = useSetupTemplateStore();
const nodeTypesStore = useNodeTypesStore();
const i18n = useI18n();
const telemetry = useTelemetry();
@@ -45,36 +59,24 @@ const appName = computed(() =>
);
const nodeNames = computed(() => {
const formatNodeName = (nodeToFormat: IWorkflowTemplateNode) => `<b>${nodeToFormat.name}</b>`;
const formatNodeName = (nodeToFormat: BaseNode) => `<b>${nodeToFormat.name}</b>`;
return formatList(props.credentials.usedBy, {
formatFn: formatNodeName,
i18n,
});
});
const selectedCredentialId = computed(
() => setupTemplateStore.selectedCredentialIdByKey[props.credentials.key],
);
//#endregion Computed
//#region Methods
const onCredentialSelected = (credentialId: string) => {
setupTemplateStore.setSelectedCredentialId(props.credentials.key, credentialId);
};
const onCredentialDeselected = () => {
setupTemplateStore.unsetSelectedCredential(props.credentials.key);
};
const onCredentialModalOpened = () => {
telemetry.track(
'User opened Credential modal',
{
source: 'cred_setup',
credentialType: props.credentials.credentialType,
new_credential: !selectedCredentialId.value,
new_credential: !props.selectedCredentialId,
},
{
withPostHog: true,
@@ -112,8 +114,15 @@ const onCredentialModalOpened = () => {
:app-name="appName"
:credential-type="props.credentials.credentialType"
:selected-credential-id="selectedCredentialId"
@credential-selected="onCredentialSelected"
@credential-deselected="onCredentialDeselected"
@credential-selected="
emit('credentialSelected', {
credentialUsageKey: $props.credentials.key,
credentialId: $event,
})
"
@credential-deselected="
emit('credentialDeselected', { credentialUsageKey: $props.credentials.key })
"
@credential-modal-opened="onCredentialModalOpened"
/>

View File

@@ -104,7 +104,10 @@ onMounted(async () => {
<template #content>
<div :class="$style.grid">
<div :class="$style.notice" data-test-id="info-callout">
<AppsRequiringCredsNotice v-if="isReady" />
<AppsRequiringCredsNotice
v-if="isReady"
:app-credentials="setupTemplateStore.appCredentials"
/>
<n8n-loading v-else variant="p" />
</div>
@@ -116,6 +119,18 @@ onMounted(async () => {
:class="$style.appCredential"
:order="index + 1"
:credentials="credentials"
:selected-credential-id="
setupTemplateStore.selectedCredentialIdByKey[credentials.key]
"
@credential-selected="
setupTemplateStore.setSelectedCredentialId(
$event.credentialUsageKey,
$event.credentialId,
)
"
@credential-deselected="
setupTemplateStore.unsetSelectedCredential($event.credentialUsageKey)
"
/>
</ol>
<div v-else :class="$style.appCredentialsContainer">

View File

@@ -1,233 +1,13 @@
import { useTemplatesStore } from '@/stores/templates.store';
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
import type {
TemplateCredentialKey,
IWorkflowTemplateNodeWithCredentials,
} from '@/utils/templates/templateTransforms';
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import {
getAppCredentials,
getAppsRequiringCredentials,
useSetupTemplateStore,
groupNodeCredentialsByKey,
} from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { useSetupTemplateStore } from '@/views/SetupWorkflowFromTemplateView/setupTemplate.store';
import { setActivePinia } from 'pinia';
import * as testData from './setupTemplate.store.testData';
import { createTestingPinia } from '@pinia/testing';
import { useCredentialsStore } from '@/stores/credentials.store';
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
const objToMap = <TKey extends string, T>(obj: Record<TKey, T>) => {
return new Map(Object.entries(obj)) as Map<TKey, T>;
};
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,
},
} satisfies Record<string, IWorkflowTemplateNodeWithCredentials>;
describe('groupNodeCredentialsByTypeAndName', () => {
it('returns an empty array if there are no nodes', () => {
expect(groupNodeCredentialsByKey([])).toEqual(new Map());
});
it('returns credentials grouped by type and name', () => {
expect(
groupNodeCredentialsByKey([
{
node: nodesByName.Twitter,
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
},
]),
).toEqual(
objToMap({
'twitterOAuth1Api-twitter': {
key: 'twitterOAuth1Api-twitter',
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
}),
);
});
it('returns credentials grouped when the credential names are the same', () => {
const [node1, node2] = [
newWorkflowTemplateNode({
type: 'n8n-nodes-base.twitter',
credentials: {
twitterOAuth1Api: 'credential',
},
}) as IWorkflowTemplateNodeWithCredentials,
newWorkflowTemplateNode({
type: 'n8n-nodes-base.telegram',
credentials: {
telegramApi: 'credential',
},
}) as IWorkflowTemplateNodeWithCredentials,
];
expect(
groupNodeCredentialsByKey([
{
node: node1,
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
},
{
node: node2,
requiredCredentials: testData.nodeTypeTelegramV1.credentials,
},
]),
).toEqual(
objToMap({
'twitterOAuth1Api-credential': {
key: 'twitterOAuth1Api-credential',
credentialName: 'credential',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [node1],
},
'telegramApi-credential': {
key: 'telegramApi-credential',
credentialName: 'credential',
credentialType: 'telegramApi',
nodeTypeName: 'n8n-nodes-base.telegram',
usedBy: [node2],
},
}),
);
});
});
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 = objToMap<TemplateCredentialKey, CredentialUsages>({
[keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter')]: {
key: keyFromCredentialTypeAndName('twitterOAuth1Api', '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[] = [
{
key: keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
];
const appNameByNodeTypeName = () => 'Twitter';
expect(getAppCredentials(credentialUsages, appNameByNodeTypeName)).toEqual([
{
appName: 'Twitter',
credentials: [
{
key: 'twitterOAuth1Api-twitter',
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
],
},
]);
});
});
describe('credentialOverrides', () => {
beforeEach(() => {
setActivePinia(
createTestingPinia({
stubActions: false,
}),
);
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentialTypes([testData.credentialTypeTelegram]);
const templatesStore = useTemplatesStore();
templatesStore.addWorkflows([testData.fullShopifyTelegramTwitterTemplate]);
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes([
testData.nodeTypeTelegramV1,
testData.nodeTypeTwitterV1,
testData.nodeTypeShopifyTriggerV1,
]);
const setupTemplateStore = useSetupTemplateStore();
setupTemplateStore.setTemplateId(testData.fullShopifyTelegramTwitterTemplate.id.toString());
});
it('should return an empty object if there are no credential overrides', () => {
// Setup
const setupTemplateStore = useSetupTemplateStore();
expect(setupTemplateStore.credentialUsages.length).toBe(3);
expect(setupTemplateStore.credentialOverrides).toEqual({});
});
it('should return overrides for one node', () => {
// Setup
const credentialsStore = useCredentialsStore();
credentialsStore.setCredentials([testData.credentialsTelegram1]);
const setupTemplateStore = useSetupTemplateStore();
setupTemplateStore.setSelectedCredentialId(
keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
testData.credentialsTelegram1.id,
);
expect(setupTemplateStore.credentialUsages.length).toBe(3);
expect(setupTemplateStore.credentialOverrides).toEqual({
'twitterOAuth1Api-twitter': {
id: testData.credentialsTelegram1.id,
name: testData.credentialsTelegram1.name,
},
});
});
});
describe('setInitialCredentialsSelection', () => {
beforeEach(() => {
setActivePinia(

View File

@@ -0,0 +1,141 @@
import { keyFromCredentialTypeAndName } from '@/utils/templates/templateTransforms';
import type { IWorkflowTemplateNodeWithCredentials } from '@/utils/templates/templateTransforms';
import type { CredentialUsages } from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
import {
getAppCredentials,
groupNodeCredentialsByKey,
} from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
import * as testData from './setupTemplate.store.testData';
import { newWorkflowTemplateNode } from '@/utils/testData/templateTestData';
const objToMap = <TKey extends string, T>(obj: Record<TKey, T>) => {
return new Map(Object.entries(obj)) as Map<TKey, T>;
};
describe('useCredentialSetupState', () => {
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,
},
} satisfies Record<string, IWorkflowTemplateNodeWithCredentials>;
describe('groupNodeCredentialsByTypeAndName', () => {
it('returns an empty array if there are no nodes', () => {
expect(groupNodeCredentialsByKey([])).toEqual(new Map());
});
it('returns credentials grouped by type and name', () => {
expect(
groupNodeCredentialsByKey([
{
node: nodesByName.Twitter,
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
},
]),
).toEqual(
objToMap({
'twitterOAuth1Api-twitter': {
key: 'twitterOAuth1Api-twitter',
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
}),
);
});
it('returns credentials grouped when the credential names are the same', () => {
const [node1, node2] = [
newWorkflowTemplateNode({
type: 'n8n-nodes-base.twitter',
credentials: {
twitterOAuth1Api: 'credential',
},
}) as IWorkflowTemplateNodeWithCredentials,
newWorkflowTemplateNode({
type: 'n8n-nodes-base.telegram',
credentials: {
telegramApi: 'credential',
},
}) as IWorkflowTemplateNodeWithCredentials,
];
expect(
groupNodeCredentialsByKey([
{
node: node1,
requiredCredentials: testData.nodeTypeTwitterV1.credentials,
},
{
node: node2,
requiredCredentials: testData.nodeTypeTelegramV1.credentials,
},
]),
).toEqual(
objToMap({
'twitterOAuth1Api-credential': {
key: 'twitterOAuth1Api-credential',
credentialName: 'credential',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [node1],
},
'telegramApi-credential': {
key: 'telegramApi-credential',
credentialName: 'credential',
credentialType: 'telegramApi',
nodeTypeName: 'n8n-nodes-base.telegram',
usedBy: [node2],
},
}),
);
});
});
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[] = [
{
key: keyFromCredentialTypeAndName('twitterOAuth1Api', 'twitter'),
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
];
const appNameByNodeTypeName = () => 'Twitter';
expect(getAppCredentials(credentialUsages, appNameByNodeTypeName)).toEqual([
{
appName: 'Twitter',
credentials: [
{
key: 'twitterOAuth1Api-twitter',
credentialName: 'twitter',
credentialType: 'twitterOAuth1Api',
nodeTypeName: 'n8n-nodes-base.twitter',
usedBy: [nodesByName.Twitter],
},
],
},
]);
});
});
});

View File

@@ -1,4 +1,3 @@
import sortBy from 'lodash-es/sortBy';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type { Router } from 'vue-router';
@@ -7,22 +6,13 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useTemplatesStore } from '@/stores/templates.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
import type { INodeCredentialsDetails, INodeTypeDescription } from 'n8n-workflow';
import type { ICredentialsResponse, INodeUi, IWorkflowTemplateNode } from '@/Interface';
import type { INodeTypeDescription } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import { VIEWS } from '@/constants';
import { createWorkflowFromTemplate } from '@/utils/templates/templateActions';
import type {
TemplateCredentialKey,
TemplateNodeWithRequiredCredential,
} from '@/utils/templates/templateTransforms';
import {
getNodesRequiringCredentials,
keyFromCredentialTypeAndName,
normalizeTemplateNodeCredentials,
} from '@/utils/templates/templateTransforms';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useTelemetry } from '@/composables/useTelemetry';
import { useCredentialSetupState } from '@/views/SetupWorkflowFromTemplateView/useCredentialSetupState';
export type NodeAndType = {
node: INodeUi;
@@ -35,131 +25,21 @@ export type RequiredCredentials = {
credentialType: string;
};
export type CredentialUsages = {
/**
* Key is a combination of the credential name and the credential type name,
* e.g. "twitter-twitterOAuth1Api"
*/
key: TemplateCredentialKey;
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 groupNodeCredentialsByKey = (
nodeWithRequiredCredentials: TemplateNodeWithRequiredCredential[],
) => {
const credentialsByTypeName = new Map<TemplateCredentialKey, CredentialUsages>();
for (const { node, requiredCredentials } of nodeWithRequiredCredentials) {
const normalizedNodeCreds = node.credentials
? normalizeTemplateNodeCredentials(node.credentials)
: {};
for (const credentialDescription of requiredCredentials) {
const credentialType = credentialDescription.name;
const nodeCredentialName = normalizedNodeCreds[credentialDescription.name] ?? '';
const key = keyFromCredentialTypeAndName(credentialType, nodeCredentialName);
let credentialUsages = credentialsByTypeName.get(key);
if (!credentialUsages) {
credentialUsages = {
key,
nodeTypeName: node.type,
credentialName: nodeCredentialName,
credentialType,
usedBy: [],
};
credentialsByTypeName.set(key, credentialUsages);
}
credentialUsages.usedBy.push(node);
}
}
return credentialsByTypeName;
};
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 selectedCredentialIdByKey = ref<
Record<CredentialUsages['key'], ICredentialsResponse['id']>
>({});
//#endregion State
const templatesStore = useTemplatesStore();
@@ -174,55 +54,21 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
return templateId.value ? templatesStore.getFullTemplateById(templateId.value) : null;
});
const nodesRequiringCredentialsSorted = computed(() => {
const nodesWithCredentials = template.value
? getNodesRequiringCredentials(nodeTypesStore, template.value)
: [];
// Order by the X coordinate of the node
return sortBy(nodesWithCredentials, ({ node }) => node.position[0]);
const templateNodes = computed(() => {
return template.value?.workflow.nodes ?? [];
});
const appNameByNodeType = (nodeTypeName: string, version?: number) => {
const nodeType = nodeTypesStore.getNodeType(nodeTypeName, version);
return nodeType ? getAppNameFromNodeName(nodeType.displayName) : nodeTypeName;
};
const credentialsByKey = computed(() => {
return groupNodeCredentialsByKey(nodesRequiringCredentialsSorted.value);
});
const credentialUsages = computed(() => {
return Array.from(credentialsByKey.value.values());
});
const appCredentials = computed(() => {
return getAppCredentials(credentialUsages.value, appNameByNodeType);
});
const credentialOverrides = computed(() => {
const overrides: Record<TemplateCredentialKey, INodeCredentialsDetails> = {};
for (const [key, credentialId] of Object.entries(selectedCredentialIdByKey.value)) {
const credential = credentialsStore.getCredentialById(credentialId);
if (!credential) {
continue;
}
// Object.entries fails to give the more accurate key type
overrides[key as TemplateCredentialKey] = {
id: credentialId,
name: credential.name,
};
}
return overrides;
});
const numFilledCredentials = computed(() => {
return Object.keys(selectedCredentialIdByKey.value).length;
});
const {
appCredentials,
credentialOverrides,
credentialUsages,
credentialsByKey,
nodesRequiringCredentialsSorted,
numFilledCredentials,
selectedCredentialIdByKey,
setSelectedCredentialId,
unsetSelectedCredential,
} = useCredentialSetupState(templateNodes);
//#endregion Getters
@@ -370,14 +216,6 @@ export const useSetupTemplateStore = defineStore('setupTemplate', () => {
}
};
const setSelectedCredentialId = (credentialKey: TemplateCredentialKey, credentialId: string) => {
selectedCredentialIdByKey.value[credentialKey] = credentialId;
};
const unsetSelectedCredential = (credentialKey: TemplateCredentialKey) => {
delete selectedCredentialIdByKey.value[credentialKey];
};
//#endregion Actions
return {

View File

@@ -0,0 +1,231 @@
import type { Ref } from 'vue';
import { computed, ref } from 'vue';
import type { ICredentialsResponse, INodeUi } from '@/Interface';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { getAppNameFromNodeName } from '@/utils/nodeTypesUtils';
import type { TemplateCredentialKey } from '@/utils/templates/templateTransforms';
import {
keyFromCredentialTypeAndName,
normalizeTemplateNodeCredentials,
} from '@/utils/templates/templateTransforms';
import type { INodeCredentialDescription, INodeCredentialsDetails } from 'n8n-workflow';
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
import { getNodeTypeDisplayableCredentials } from '@/utils/nodes/nodeTransforms';
import sortBy from 'lodash-es/sortBy';
//#region Types
export type NodeCredentials = {
[key: string]: string | INodeCredentialsDetails;
};
/**
* Node that can either be in a workflow or in a template workflow. These
* have a bit different shape and this type is used to represent both.
*/
export type BaseNode = Pick<
INodeUi,
'name' | 'parameters' | 'position' | 'type' | 'typeVersion'
> & {
credentials?: NodeCredentials;
};
export type NodeWithCredentials<TNode extends BaseNode> = TNode & {
credentials: NodeCredentials;
};
export type NodeWithRequiredCredential<TNode extends BaseNode> = {
node: TNode;
requiredCredentials: INodeCredentialDescription[];
};
export type CredentialUsages<TNode extends BaseNode = BaseNode> = {
/**
* Key is a combination of the credential name and the credential type name,
* e.g. "twitter-twitterOAuth1Api"
*/
key: TemplateCredentialKey;
credentialName: string;
credentialType: string;
nodeTypeName: string;
usedBy: TNode[];
};
export type AppCredentials<TNode extends BaseNode> = {
appName: string;
credentials: Array<CredentialUsages<TNode>>;
};
//#endregion Types
//#region Getters
/**
* Returns the nodes in the template that require credentials
* and the required credentials for each node.
*/
export const getNodesRequiringCredentials = <TNode extends BaseNode>(
nodeTypeProvider: NodeTypeProvider,
nodes: TNode[],
): Array<NodeWithRequiredCredential<TNode>> => {
const nodesWithCredentials: Array<NodeWithRequiredCredential<TNode>> = nodes
.map((node) => ({
node,
requiredCredentials: getNodeTypeDisplayableCredentials(nodeTypeProvider, node),
}))
.filter(({ requiredCredentials }) => requiredCredentials.length > 0);
return nodesWithCredentials;
};
export const groupNodeCredentialsByKey = <TNode extends BaseNode>(
nodeWithRequiredCredentials: Array<NodeWithRequiredCredential<TNode>>,
) => {
const credentialsByTypeName = new Map<TemplateCredentialKey, CredentialUsages<TNode>>();
for (const { node, requiredCredentials } of nodeWithRequiredCredentials) {
const normalizedNodeCreds = node.credentials
? normalizeTemplateNodeCredentials(node.credentials)
: {};
for (const credentialDescription of requiredCredentials) {
const credentialType = credentialDescription.name;
const nodeCredentialName = normalizedNodeCreds[credentialDescription.name] ?? '';
const key = keyFromCredentialTypeAndName(credentialType, nodeCredentialName);
let credentialUsages = credentialsByTypeName.get(key);
if (!credentialUsages) {
credentialUsages = {
key,
nodeTypeName: node.type,
credentialName: nodeCredentialName,
credentialType,
usedBy: [],
};
credentialsByTypeName.set(key, credentialUsages);
}
credentialUsages.usedBy.push(node);
}
}
return credentialsByTypeName;
};
export const getAppCredentials = <TNode extends BaseNode>(
credentialUsages: Array<CredentialUsages<TNode>>,
getAppNameByNodeType: (nodeTypeName: string, version?: number) => string,
) => {
const credentialsByAppName = new Map<string, AppCredentials<TNode>>();
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());
};
//#endregion Getters
export const useCredentialSetupState = <TNode extends BaseNode>(nodes: Ref<TNode[]>) => {
/**
* Credentials user has selected from the UI. Map from credential
* name in the template to the credential ID.
*/
const selectedCredentialIdByKey = ref<
Record<CredentialUsages<TNode>['key'], ICredentialsResponse['id']>
>({});
const nodeTypesStore = useNodeTypesStore();
const credentialsStore = useCredentialsStore();
const appNameByNodeType = (nodeTypeName: string, version?: number) => {
const nodeType = nodeTypesStore.getNodeType(nodeTypeName, version);
return nodeType ? getAppNameFromNodeName(nodeType.displayName) : nodeTypeName;
};
//#region Computed
const nodesRequiringCredentialsSorted = computed(() => {
const nodesWithCredentials = nodes.value
? getNodesRequiringCredentials(nodeTypesStore, nodes.value)
: [];
// Order by the X coordinate of the node
return sortBy(nodesWithCredentials, ({ node }) => node.position[0]);
});
const credentialsByKey = computed(() => {
return groupNodeCredentialsByKey(nodesRequiringCredentialsSorted.value);
});
const credentialUsages = computed(() => {
return Array.from(credentialsByKey.value.values());
});
const appCredentials = computed(() => {
return getAppCredentials(credentialUsages.value, appNameByNodeType);
});
const credentialOverrides = computed(() => {
const overrides: Record<TemplateCredentialKey, INodeCredentialsDetails> = {};
for (const [key, credentialId] of Object.entries(selectedCredentialIdByKey.value)) {
const credential = credentialsStore.getCredentialById(credentialId);
if (!credential) {
continue;
}
// Object.entries fails to give the more accurate key type
overrides[key as TemplateCredentialKey] = {
id: credentialId,
name: credential.name,
};
}
return overrides;
});
const numFilledCredentials = computed(() => {
return Object.keys(selectedCredentialIdByKey.value).length;
});
//#endregion Computed
//#region Actions
const setSelectedCredentialId = (credentialKey: TemplateCredentialKey, credentialId: string) => {
selectedCredentialIdByKey.value[credentialKey] = credentialId;
};
const unsetSelectedCredential = (credentialKey: TemplateCredentialKey) => {
delete selectedCredentialIdByKey.value[credentialKey];
};
//#endregion Actions
return {
appCredentials,
credentialOverrides,
credentialUsages,
credentialsByKey,
nodesRequiringCredentialsSorted,
numFilledCredentials,
selectedCredentialIdByKey,
setSelectedCredentialId,
unsetSelectedCredential,
};
};