fix(editor): Fix performance issue in credentials list (#10988)

This commit is contained in:
Elias Meire 2024-09-27 12:04:00 +02:00 committed by GitHub
parent d2bc0760e2
commit 7073ec6fe5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 191 additions and 129 deletions

View file

@ -30,18 +30,6 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
const loader = new PackageDirectoryLoader(packageDir); const loader = new PackageDirectoryLoader(packageDir);
await loader.loadAll(); await loader.loadAll();
const knownCredentials = loader.known.credentials;
const credentialTypes = Object.values(loader.credentialTypes).map((data) => {
const credentialType = data.type;
if (
knownCredentials[credentialType.name].supportedNodes?.length > 0 &&
credentialType.httpRequestNode
) {
credentialType.httpRequestNode.hidden = true;
}
return credentialType;
});
const loaderNodeTypes = Object.values(loader.nodeTypes); const loaderNodeTypes = Object.values(loader.nodeTypes);
const definedMethods = loaderNodeTypes.reduce((acc, cur) => { const definedMethods = loaderNodeTypes.reduce((acc, cur) => {
@ -76,6 +64,36 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
}), }),
); );
const knownCredentials = loader.known.credentials;
const credentialTypes = Object.values(loader.credentialTypes).map((data) => {
const credentialType = data.type;
const supportedNodes = knownCredentials[credentialType.name].supportedNodes ?? [];
if (supportedNodes.length > 0 && credentialType.httpRequestNode) {
credentialType.httpRequestNode.hidden = true;
}
credentialType.supportedNodes = supportedNodes;
if (!credentialType.iconUrl && !credentialType.icon) {
for (const supportedNode of supportedNodes) {
const nodeType = loader.nodeTypes[supportedNode]?.type.description;
if (!nodeType) continue;
if (nodeType.icon) {
credentialType.icon = nodeType.icon;
credentialType.iconColor = nodeType.iconColor;
break;
}
if (nodeType.iconUrl) {
credentialType.iconUrl = nodeType.iconUrl;
break;
}
}
}
return credentialType;
});
const referencedMethods = findReferencedMethods(nodeTypes); const referencedMethods = findReferencedMethods(nodeTypes);
await Promise.all([ await Promise.all([

View file

@ -1,50 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { useRootStore } from '@/stores/root.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { ICredentialType } from 'n8n-workflow'; import { useRootStore } from '@/stores/root.store';
import NodeIcon from '@/components/NodeIcon.vue';
import { getThemedValue } from '@/utils/nodeTypesUtils';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { getThemedValue } from '@/utils/nodeTypesUtils';
import { N8nNodeIcon } from 'n8n-design-system';
import type { ICredentialType } from 'n8n-workflow';
import { computed } from 'vue';
const props = defineProps<{ const props = defineProps<{
credentialTypeName: string | null; credentialTypeName: string | null;
}>(); }>();
const credentialsStore = useCredentialsStore(); const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const rootStore = useRootStore(); const rootStore = useRootStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore();
const credentialWithIcon = computed(() => getCredentialWithIcon(props.credentialTypeName)); const credentialWithIcon = computed(() => getCredentialWithIcon(props.credentialTypeName));
const filePath = computed(() => { const nodeBasedIconUrl = computed(() => {
const themeIconUrl = getThemedValue(credentialWithIcon.value?.iconUrl, uiStore.appliedTheme); const icon = getThemedValue(credentialWithIcon.value?.icon);
if (!icon?.startsWith('node:')) return null;
return nodeTypesStore.getNodeType(icon.replace('node:', ''))?.iconUrl;
});
const iconSource = computed(() => {
const themeIconUrl = getThemedValue(
nodeBasedIconUrl.value ?? credentialWithIcon.value?.iconUrl,
uiStore.appliedTheme,
);
if (!themeIconUrl) { if (!themeIconUrl) {
return null; return undefined;
} }
return rootStore.baseUrl + themeIconUrl; return rootStore.baseUrl + themeIconUrl;
}); });
const relevantNode = computed(() => { const iconType = computed(() => {
const icon = credentialWithIcon.value?.icon; if (iconSource.value) return 'file';
if (typeof icon === 'string' && icon.startsWith('node:')) { else if (iconName.value) return 'icon';
const nodeType = icon.replace('node:', ''); return 'unknown';
return nodeTypesStore.getNodeType(nodeType); });
}
if (!props.credentialTypeName) {
return null;
}
const nodesWithAccess = credentialsStore.getNodesWithAccess(props.credentialTypeName); const iconName = computed(() => {
if (nodesWithAccess.length) { const icon = getThemedValue(credentialWithIcon.value?.icon, uiStore.appliedTheme);
return nodesWithAccess[0]; if (!icon || !icon?.startsWith('fa:')) return undefined;
} return icon.replace('fa:', '');
});
return null; const iconColor = computed(() => {
const { iconColor: color } = credentialWithIcon.value ?? {};
if (!color) return undefined;
return `var(--color-node-icon-${color})`;
}); });
function getCredentialWithIcon(name: string | null): ICredentialType | null { function getCredentialWithIcon(name: string | null): ICredentialType | null {
@ -64,8 +73,8 @@ function getCredentialWithIcon(name: string | null): ICredentialType | null {
if (type.extends) { if (type.extends) {
let parentCred = null; let parentCred = null;
type.extends.forEach((iconName) => { type.extends.forEach((credType) => {
parentCred = getCredentialWithIcon(iconName); parentCred = getCredentialWithIcon(credType);
if (parentCred !== null) return; if (parentCred !== null) return;
}); });
return parentCred; return parentCred;
@ -76,23 +85,18 @@ function getCredentialWithIcon(name: string | null): ICredentialType | null {
</script> </script>
<template> <template>
<div> <N8nNodeIcon
<img v-if="filePath" :class="$style.credIcon" :src="filePath" /> :class="$style.icon"
<NodeIcon v-else-if="relevantNode" :node-type="relevantNode" :size="28" /> :type="iconType"
<span v-else :class="$style.fallback"></span> :size="26"
</div> :src="iconSource"
:name="iconName"
:color="iconColor"
/>
</template> </template>
<style lang="scss" module> <style lang="scss" module>
.credIcon { .icon {
height: 26px; --node-icon-color: var(--color-foreground-dark);
}
.fallback {
height: 28px;
width: 28px;
display: flex;
border-radius: 50%;
background-color: var(--color-foreground-base);
} }
</style> </style>

View file

@ -60,6 +60,7 @@ function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, label?: stri
categories: [category], categories: [category],
}, },
iconUrl: nodeTypeDescription.iconUrl, iconUrl: nodeTypeDescription.iconUrl,
iconColor: nodeTypeDescription.iconColor,
outputs: nodeTypeDescription.outputs, outputs: nodeTypeDescription.outputs,
icon: nodeTypeDescription.icon, icon: nodeTypeDescription.icon,
defaults: nodeTypeDescription.defaults, defaults: nodeTypeDescription.defaults,

View file

@ -1,66 +1,111 @@
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import type { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import CredentialIcon from '@/components/CredentialIcon.vue'; import CredentialIcon from '@/components/CredentialIcon.vue';
import { STORES } from '@/constants';
import { groupNodeTypesByNameAndType } from '@/utils/nodeTypes/nodeTypeTransforms';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useRootStore } from '@/stores/root.store';
import { useNodeTypesStore } from '../../stores/nodeTypes.store';
const twitterV1 = mock<INodeTypeDescription>({ describe('CredentialIcon', () => {
version: 1,
credentials: [{ name: 'twitterOAuth1Api', required: true }],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
});
const twitterV2 = mock<INodeTypeDescription>({
version: 2,
credentials: [{ name: 'twitterOAuth2Api', required: true }],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
});
const nodeTypes = groupNodeTypesByNameAndType([twitterV1, twitterV2]);
const initialState = {
[STORES.CREDENTIALS]: {},
[STORES.NODE_TYPES]: { nodeTypes },
};
const renderComponent = createComponentRenderer(CredentialIcon, { const renderComponent = createComponentRenderer(CredentialIcon, {
pinia: createTestingPinia({ initialState }), pinia: createTestingPinia(),
global: { global: {
stubs: ['n8n-tooltip'], stubs: ['n8n-tooltip'],
}, },
}); });
let pinia: TestingPinia;
describe('CredentialIcon', () => { beforeEach(() => {
const findIcon = (baseElement: Element) => baseElement.querySelector('img'); pinia = createTestingPinia({ stubActions: false });
});
it('shows correct icon for credential type that is for the latest node type version', () => { it('shows correct icon when iconUrl is set on credential', () => {
const { baseElement } = renderComponent({ const testIconUrl = 'icons/n8n-nodes-base/dist/nodes/Test/test.svg';
pinia: createTestingPinia({ initialState }), useCredentialsStore().setCredentialTypes([
mock<ICredentialType>({
name: 'test',
iconUrl: testIconUrl,
}),
]);
const { getByRole } = renderComponent({
pinia,
props: { props: {
credentialTypeName: 'twitterOAuth2Api', credentialTypeName: 'test',
}, },
}); });
expect(findIcon(baseElement)).toHaveAttribute( expect(getByRole('img')).toHaveAttribute('src', useRootStore().baseUrl + testIconUrl);
'src',
'/icons/n8n-nodes-base/dist/nodes/Twitter/x.svg',
);
}); });
it('shows correct icon for credential type that is for an older node type version', () => { it('shows correct icon when icon is set on credential', () => {
const { baseElement } = renderComponent({ useCredentialsStore().setCredentialTypes([
pinia: createTestingPinia({ initialState }), mock<ICredentialType>({
name: 'test',
icon: 'fa:clock',
iconColor: 'azure',
}),
]);
const { getByRole } = renderComponent({
pinia,
props: { props: {
credentialTypeName: 'twitterOAuth1Api', credentialTypeName: 'test',
}, },
}); });
expect(findIcon(baseElement)).toHaveAttribute( const icon = getByRole('img', { hidden: true });
'src', expect(icon.tagName).toBe('svg');
'/icons/n8n-nodes-base/dist/nodes/Twitter/x.svg', expect(icon).toHaveClass('fa-clock');
); });
it('shows correct icon when credential has an icon with node: prefix', () => {
const testIconUrl = 'icons/n8n-nodes-base/dist/nodes/Test/test.svg';
useCredentialsStore().setCredentialTypes([
mock<ICredentialType>({
name: 'test',
icon: 'node:n8n-nodes-base.test',
iconColor: 'azure',
}),
]);
useNodeTypesStore().setNodeTypes([
mock<INodeTypeDescription>({
version: 1,
name: 'n8n-nodes-base.test',
iconUrl: testIconUrl,
}),
]);
const { getByRole } = renderComponent({
pinia,
props: {
credentialTypeName: 'test',
},
});
expect(getByRole('img')).toHaveAttribute('src', useRootStore().baseUrl + testIconUrl);
});
it('shows fallback icon when icon is not found', () => {
useCredentialsStore().setCredentialTypes([
mock<ICredentialType>({
name: 'test',
icon: 'node:n8n-nodes-base.test',
iconColor: 'azure',
}),
]);
const { baseElement } = renderComponent({
pinia,
props: {
credentialTypeName: 'test',
},
});
expect(baseElement.querySelector('.nodeIconPlaceholder')).toBeInTheDocument();
}); });
}); });

View file

@ -1,32 +1,31 @@
import type { import type {
INodeUi,
IUsedCredential,
ICredentialMap, ICredentialMap,
ICredentialsDecryptedResponse, ICredentialsDecryptedResponse,
ICredentialsResponse, ICredentialsResponse,
ICredentialsState, ICredentialsState,
ICredentialTypeMap, ICredentialTypeMap,
INodeUi,
IUsedCredential,
} from '@/Interface'; } from '@/Interface';
import * as credentialsApi from '@/api/credentials'; import * as credentialsApi from '@/api/credentials';
import * as credentialsEeApi from '@/api/credentials.ee'; import * as credentialsEeApi from '@/api/credentials.ee';
import { makeRestApiRequest } from '@/utils/apiUtils';
import { getAppNameFromCredType } from '@/utils/nodeTypesUtils';
import { EnterpriseEditionFeature, STORES } from '@/constants'; import { EnterpriseEditionFeature, STORES } from '@/constants';
import { i18n } from '@/plugins/i18n'; import { i18n } from '@/plugins/i18n';
import type { ProjectSharingData } from '@/types/projects.types';
import { makeRestApiRequest } from '@/utils/apiUtils';
import { getAppNameFromCredType } from '@/utils/nodeTypesUtils';
import { splitName } from '@/utils/projects.utils';
import { isEmpty, isPresent } from '@/utils/typesUtils';
import type { import type {
ICredentialsDecrypted, ICredentialsDecrypted,
ICredentialType, ICredentialType,
INodeCredentialTestResult, INodeCredentialTestResult,
INodeTypeDescription,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useRootStore } from './root.store';
import { useNodeTypesStore } from './nodeTypes.store';
import { useSettingsStore } from './settings.store';
import { isEmpty } from '@/utils/typesUtils';
import type { ProjectSharingData } from '@/types/projects.types';
import { splitName } from '@/utils/projects.utils';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useNodeTypesStore } from './nodeTypes.store';
import { useRootStore } from './root.store';
import { useSettingsStore } from './settings.store';
const DEFAULT_CREDENTIAL_NAME = 'Unnamed credential'; const DEFAULT_CREDENTIAL_NAME = 'Unnamed credential';
const DEFAULT_CREDENTIAL_POSTFIX = 'account'; const DEFAULT_CREDENTIAL_POSTFIX = 'account';
@ -131,22 +130,15 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
const getNodesWithAccess = computed(() => { const getNodesWithAccess = computed(() => {
return (credentialTypeName: string) => { return (credentialTypeName: string) => {
const credentialType = getCredentialTypeByName.value(credentialTypeName);
if (!credentialType) {
return [];
}
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const allNodeTypes: INodeTypeDescription[] = nodeTypesStore.allNodeTypes;
return allNodeTypes.filter((nodeType: INodeTypeDescription) => { return (credentialType.supportedNodes ?? [])
if (!nodeType.credentials) { .map((nodeType) => nodeTypesStore.getNodeType(nodeType))
return false; .filter(isPresent);
}
for (const credentialTypeDescription of nodeType.credentials) {
if (credentialTypeDescription.name === credentialTypeName) {
return true;
}
}
return false;
});
}; };
}); });

View file

@ -314,6 +314,7 @@ export interface ICredentialType {
name: string; name: string;
displayName: string; displayName: string;
icon?: Icon; icon?: Icon;
iconColor?: ThemeIconColor;
iconUrl?: Themed<string>; iconUrl?: Themed<string>;
extends?: string[]; extends?: string[];
properties: INodeProperties[]; properties: INodeProperties[];
@ -327,6 +328,7 @@ export interface ICredentialType {
test?: ICredentialTestRequest; test?: ICredentialTestRequest;
genericAuth?: boolean; genericAuth?: boolean;
httpRequestNode?: ICredentialHttpRequestNode; httpRequestNode?: ICredentialHttpRequestNode;
supportedNodes?: string[];
} }
export interface ICredentialTypes { export interface ICredentialTypes {
@ -1617,7 +1619,7 @@ export interface IWorkflowIssues {
[key: string]: INodeIssues; [key: string]: INodeIssues;
} }
export type NodeIconColor = export type ThemeIconColor =
| 'gray' | 'gray'
| 'black' | 'black'
| 'blue' | 'blue'
@ -1642,7 +1644,7 @@ export interface INodeTypeBaseDescription {
displayName: string; displayName: string;
name: string; name: string;
icon?: Icon; icon?: Icon;
iconColor?: NodeIconColor; iconColor?: ThemeIconColor;
iconUrl?: Themed<string>; iconUrl?: Themed<string>;
badgeIconUrl?: Themed<string>; badgeIconUrl?: Themed<string>;
group: string[]; group: string[];