diff --git a/cypress/e2e/16-webhook-node.cy.ts b/cypress/e2e/16-webhook-node.cy.ts index 3d6c1049a2b61..193ada0bcc6d5 100644 --- a/cypress/e2e/16-webhook-node.cy.ts +++ b/cypress/e2e/16-webhook-node.cy.ts @@ -250,7 +250,7 @@ describe('Webhook Trigger node', () => { }); // add credentials workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.actions.fillCredentialsForm(); @@ -293,7 +293,7 @@ describe('Webhook Trigger node', () => { }); // add credentials workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.actions.fillCredentialsForm(); diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts index 30a990fb28b4a..60eb474b07f7e 100644 --- a/cypress/e2e/17-sharing.cy.ts +++ b/cypress/e2e/17-sharing.cy.ts @@ -297,10 +297,9 @@ describe('Credential Usage in Cross Shared Workflows', () => { workflowsPage.actions.createWorkflowFromCard(); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); - // Only the credential in this project (+ the 'Create new' option) should - // be in the dropdown + // Only the credential in this project should be in the dropdown workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').should('have.length', 2); + getVisibleSelect().find('li').should('have.length', 1); }); it('should only show credentials in their personal project for members', () => { @@ -325,10 +324,9 @@ describe('Credential Usage in Cross Shared Workflows', () => { workflowsPage.actions.createWorkflowFromCard(); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); - // Only the own credential the shared one (+ the 'Create new' option) - // should be in the dropdown + // Only the own credential the shared one should be in the dropdown workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').should('have.length', 3); + getVisibleSelect().find('li').should('have.length', 2); }); it('should only show credentials in their personal project for members if the workflow was shared with them', () => { @@ -355,10 +353,9 @@ describe('Credential Usage in Cross Shared Workflows', () => { workflowsPage.getters.workflowCardContent(workflowName).click(); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); - // Only the own credential the shared one (+ the 'Create new' option) - // should be in the dropdown + // Only the own credential the shared one should be in the dropdown workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').should('have.length', 2); + getVisibleSelect().find('li').should('have.length', 1); }); it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => { @@ -400,10 +397,9 @@ describe('Credential Usage in Cross Shared Workflows', () => { workflowsPage.getters.workflowCardContent(workflowName).click(); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); - // Only the personal credentials of the workflow owner and the global owner - // should show up. + // Only the personal credentials of the workflow owner and the global owner should show up. workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').should('have.length', 4); + getVisibleSelect().find('li').should('have.length', 3); }); it('should show all personal credentials if the global owner owns the workflow', () => { @@ -421,6 +417,6 @@ describe('Credential Usage in Cross Shared Workflows', () => { // Show all personal credentials workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').should('have.have.length', 2); + getVisibleSelect().find('li').should('have.have.length', 1); }); }); diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts index cda01c71a36a7..260d5f63a07ec 100644 --- a/cypress/e2e/2-credentials.cy.ts +++ b/cypress/e2e/2-credentials.cy.ts @@ -31,7 +31,7 @@ function createNotionCredential() { workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME); workflowPage.actions.openNode(NOTION_NODE_NAME); workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.actions.fillCredentialsForm(); cy.get('body').type('{esc}'); workflowPage.actions.deleteNode(NOTION_NODE_NAME); @@ -79,7 +79,7 @@ describe('Credentials', () => { workflowPage.getters.canvasNodes().last().click(); cy.get('body').type('{enter}'); workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); @@ -99,7 +99,7 @@ describe('Credentials', () => { cy.get('body').type('{enter}'); workflowPage.getters.nodeCredentialsSelect().click(); // Add oAuth credentials - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); @@ -107,14 +107,13 @@ describe('Credentials', () => { cy.get('.el-message-box').find('button').contains('Close').click(); workflowPage.getters.nodeCredentialsSelect().click(); // Add Service account credentials - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); credentialsModal.getters.credentialAuthTypeRadioButtons().last().click(); credentialsModal.actions.fillCredentialsForm(); - // Both (+ the 'Create new' option) should be in the dropdown workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').should('have.length.greaterThan', 2); + getVisibleSelect().find('li').should('have.length', 3); }); it('should correctly render required and optional credentials', () => { @@ -130,13 +129,13 @@ describe('Credentials', () => { workflowPage.getters.nodeCredentialsSelect().should('have.length', 2); workflowPage.getters.nodeCredentialsSelect().first().click(); - getVisibleSelect().find('li').contains('Create New Credential').click(); + workflowPage.getters.nodeCredentialsCreateOption().first().click(); // This one should show auth type selector credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2); cy.get('body').type('{esc}'); workflowPage.getters.nodeCredentialsSelect().last().click(); - getVisibleSelect().find('li').contains('Create New Credential').click(); + workflowPage.getters.nodeCredentialsCreateOption().last().click(); // This one should not show auth type selector credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist'); }); @@ -148,7 +147,7 @@ describe('Credentials', () => { workflowPage.getters.canvasNodes().last().click(); cy.get('body').type('{enter}'); workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist'); credentialsModal.actions.fillCredentialsForm(); workflowPage.getters @@ -164,7 +163,7 @@ describe('Credentials', () => { workflowPage.getters.canvasNodes().last().click(); cy.get('body').type('{enter}'); workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.actions.fillCredentialsForm(); workflowPage.getters .nodeCredentialsSelect() @@ -189,7 +188,7 @@ describe('Credentials', () => { workflowPage.getters.canvasNodes().last().click(); cy.get('body').type('{enter}'); workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.actions.fillCredentialsForm(); workflowPage.getters.nodeCredentialsEditButton().click(); credentialsModal.getters.credentialsEditModal().should('be.visible'); @@ -232,7 +231,7 @@ describe('Credentials', () => { cy.getByTestId('credential-select').click(); cy.contains('Adalo API').click(); workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.actions.fillCredentialsForm(); workflowPage.getters.nodeCredentialsEditButton().click(); credentialsModal.getters.credentialsEditModal().should('be.visible'); @@ -296,7 +295,7 @@ describe('Credentials', () => { workflowPage.getters.nodeCredentialsSelect().should('exist'); workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.actions.fillCredentialsForm(); workflowPage.getters .nodeCredentialsSelect() @@ -325,7 +324,7 @@ describe('Credentials', () => { workflowPage.actions.addNodeToCanvas('Slack', true, true, 'Get a channel'); workflowPage.getters.nodeCredentialsSelect().should('exist'); workflowPage.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); nodeDetailsView.getters.copyInput().should('not.exist'); }); diff --git a/cypress/e2e/21-community-nodes.cy.ts b/cypress/e2e/21-community-nodes.cy.ts index 17f82ec573ec9..283c08d557522 100644 --- a/cypress/e2e/21-community-nodes.cy.ts +++ b/cypress/e2e/21-community-nodes.cy.ts @@ -89,7 +89,7 @@ describe('Community and custom nodes in canvas', () => { workflowPage.actions.addNodeToCanvas('Manual'); workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true); workflowPage.getters.nodeCredentialsLabel().click(); - cy.contains('Create New Credential').click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.editCredentialModal().should('be.visible'); credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API'); }); @@ -98,7 +98,7 @@ describe('Community and custom nodes in canvas', () => { workflowPage.actions.addNodeToCanvas('Manual'); workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true); workflowPage.getters.nodeCredentialsLabel().click(); - cy.contains('Create New Credential').click(); + workflowPage.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.editCredentialModal().should('be.visible'); credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential'); }); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index efd18bca74bcb..197d5852566df 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -367,7 +367,7 @@ describe('Projects', { disableAutoLogin: true }, () => { workflowPage.getters.nodeCredentialsSelect().first().click(); getVisibleSelect() .find('li') - .should('have.length', 2) + .should('have.length', 1) .first() .should('contain.text', 'Notion account project 1'); ndv.getters.backToCanvas().click(); @@ -382,7 +382,7 @@ describe('Projects', { disableAutoLogin: true }, () => { workflowPage.getters.nodeCredentialsSelect().first().click(); getVisibleSelect() .find('li') - .should('have.length', 2) + .should('have.length', 1) .first() .should('contain.text', 'Notion account project 1'); ndv.getters.backToCanvas().click(); @@ -396,7 +396,7 @@ describe('Projects', { disableAutoLogin: true }, () => { workflowPage.getters.nodeCredentialsSelect().first().click(); getVisibleSelect() .find('li') - .should('have.length', 2) + .should('have.length', 1) .first() .should('contain.text', 'Notion account project 2'); ndv.getters.backToCanvas().click(); @@ -407,7 +407,7 @@ describe('Projects', { disableAutoLogin: true }, () => { workflowPage.getters.nodeCredentialsSelect().first().click(); getVisibleSelect() .find('li') - .should('have.length', 2) + .should('have.length', 1) .first() .should('contain.text', 'Notion account project 2'); ndv.getters.backToCanvas().click(); @@ -425,7 +425,7 @@ describe('Projects', { disableAutoLogin: true }, () => { workflowPage.getters.nodeCredentialsSelect().first().click(); getVisibleSelect() .find('li') - .should('have.length', 2) + .should('have.length', 1) .first() .should('contain.text', 'Notion account personal project'); ndv.getters.backToCanvas().click(); @@ -436,7 +436,7 @@ describe('Projects', { disableAutoLogin: true }, () => { workflowPage.getters.nodeCredentialsSelect().first().click(); getVisibleSelect() .find('li') - .should('have.length', 2) + .should('have.length', 1) .first() .should('contain.text', 'Notion account personal project'); }); diff --git a/cypress/e2e/45-ai-assistant.cy.ts b/cypress/e2e/45-ai-assistant.cy.ts index 9d3381136327b..157c656b46705 100644 --- a/cypress/e2e/45-ai-assistant.cy.ts +++ b/cypress/e2e/45-ai-assistant.cy.ts @@ -5,7 +5,6 @@ import { GMAIL_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME } from '../constants'; import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages'; import { AIAssistant } from '../pages/features/ai-assistant'; import { NodeCreator } from '../pages/features/node-creator'; -import { getVisibleSelect } from '../utils'; const wf = new WorkflowPage(); const ndv = new NDV(); @@ -434,7 +433,7 @@ describe('AI Assistant Credential Help', () => { wf.actions.addNodeToCanvas('Slack', true, true, 'Get a channel'); wf.getters.nodeCredentialsSelect().should('exist'); wf.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + wf.getters.nodeCredentialsCreateOption().click(); credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); ndv.getters.copyInput().should('not.exist'); credentialsModal.getters.oauthConnectButton().should('have.length', 1); @@ -467,7 +466,7 @@ describe('AI Assistant Credential Help', () => { wf.actions.addNodeToCanvas('Microsoft Outlook', true, true, 'Get a calendar'); wf.getters.nodeCredentialsSelect().should('exist'); wf.getters.nodeCredentialsSelect().click(); - getVisibleSelect().find('li').last().click(); + wf.getters.nodeCredentialsCreateOption().click(); ndv.getters.copyInput().should('not.exist'); credentialsModal.getters.oauthConnectButton().should('have.length', 1); credentialsModal.getters.credentialInputs().should('have.length', 1); diff --git a/packages/design-system/src/components/N8nSelect/Select.vue b/packages/design-system/src/components/N8nSelect/Select.vue index 80a065d429947..28c1fc049a87e 100644 --- a/packages/design-system/src/components/N8nSelect/Select.vue +++ b/packages/design-system/src/components/N8nSelect/Select.vue @@ -136,6 +136,12 @@ defineExpose({ + + diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 3b6b7451be84c..66ad82a4d7e08 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -37,6 +37,8 @@ // Danger --color-danger-shade-1: var(--prim-color-alt-c-shade-100); --color-danger: var(--prim-color-alt-c); + --color-danger-light: var(--prim-color-alt-c-tint-150); + --color-danger-light-2: var(--prim-color-alt-c-tint-250); --color-danger-tint-1: var(--prim-color-alt-c-tint-400); --color-danger-tint-2: var(--prim-color-alt-c-tint-450); diff --git a/packages/editor-ui/src/components/NodeCredentials.test.ts b/packages/editor-ui/src/components/NodeCredentials.test.ts index 2839a75c031c3..e9a461ebe29e5 100644 --- a/packages/editor-ui/src/components/NodeCredentials.test.ts +++ b/packages/editor-ui/src/components/NodeCredentials.test.ts @@ -1,5 +1,6 @@ import { describe, it } from 'vitest'; -import { fireEvent, screen } from '@testing-library/vue'; +import { screen } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; import { createTestingPinia } from '@pinia/testing'; import NodeCredentials from './NodeCredentials.vue'; import type { RenderOptions } from '@/__tests__/render'; @@ -8,6 +9,7 @@ import { useCredentialsStore } from '@/stores/credentials.store'; import { mockedStore } from '@/__tests__/utils'; import type { INodeUi } from '@/Interface'; import { useNDVStore } from '@/stores/ndv.store'; +import { useUIStore } from '../stores/ui.store'; const httpNode: INodeUi = { parameters: { @@ -67,6 +69,7 @@ describe('NodeCredentials', () => { const credentialsStore = mockedStore(useCredentialsStore); const ndvStore = mockedStore(useNDVStore); + const uiStore = mockedStore(useUIStore); beforeAll(() => { credentialsStore.state.credentialTypes = { @@ -120,7 +123,7 @@ describe('NodeCredentials', () => { const credentialsSelect = screen.getByTestId('node-credentials-select'); - await fireEvent.click(credentialsSelect); + await userEvent.click(credentialsSelect); expect(screen.queryByText('OpenAi account')).toBeInTheDocument(); }); @@ -150,7 +153,7 @@ describe('NodeCredentials', () => { const credentialsSelect = screen.getByTestId('node-credentials-select'); - await fireEvent.click(credentialsSelect); + await userEvent.click(credentialsSelect); expect(screen.queryByText('OpenAi account')).toBeInTheDocument(); expect(screen.queryByText('OpenAi account 2')).not.toBeInTheDocument(); @@ -188,9 +191,69 @@ describe('NodeCredentials', () => { const credentialsSelect = screen.getByTestId('node-credentials-select'); - await fireEvent.click(credentialsSelect); + await userEvent.click(credentialsSelect); expect(screen.queryByText('OpenAi account')).toBeInTheDocument(); expect(screen.queryByText('OpenAi account 2')).toBeInTheDocument(); }); + + it('should filter available credentials in the dropdown', async () => { + ndvStore.activeNode = httpNode; + credentialsStore.state.credentials = { + c8vqdPpPClh4TgIO: { + id: 'c8vqdPpPClh4TgIO', + name: 'OpenAi account', + type: 'openAiApi', + isManaged: false, + createdAt: '', + updatedAt: '', + }, + test: { + id: 'test', + name: 'Test OpenAi account', + type: 'openAiApi', + isManaged: false, + createdAt: '', + updatedAt: '', + }, + }; + + renderComponent(); + + const credentialsSelect = screen.getByTestId('node-credentials-select'); + + await userEvent.click(credentialsSelect); + + expect(screen.queryByText('OpenAi account')).toBeInTheDocument(); + expect(screen.queryByText('Test OpenAi account')).toBeInTheDocument(); + + const credentialSearch = credentialsSelect.querySelector('input') as HTMLElement; + await userEvent.type(credentialSearch, 'test'); + + expect(screen.queryByText('OpenAi account')).not.toBeInTheDocument(); + expect(screen.queryByText('Test OpenAi account')).toBeInTheDocument(); + }); + + it('should open the new credential modal when clicked', async () => { + ndvStore.activeNode = httpNode; + credentialsStore.state.credentials = { + c8vqdPpPClh4TgIO: { + id: 'c8vqdPpPClh4TgIO', + name: 'OpenAi account', + type: 'openAiApi', + isManaged: false, + createdAt: '', + updatedAt: '', + }, + }; + + renderComponent(); + + const credentialsSelect = screen.getByTestId('node-credentials-select'); + + await userEvent.click(credentialsSelect); + await userEvent.click(screen.getByTestId('node-credentials-select-item-new')); + + expect(uiStore.openNewCredential).toHaveBeenCalledWith('openAiApi', true); + }); }); diff --git a/packages/editor-ui/src/components/NodeCredentials.vue b/packages/editor-ui/src/components/NodeCredentials.vue index 8fe4b1515315e..22e4c908afc47 100644 --- a/packages/editor-ui/src/components/NodeCredentials.vue +++ b/packages/editor-ui/src/components/NodeCredentials.vue @@ -2,11 +2,12 @@ import type { ICredentialsResponse, INodeUi, INodeUpdatePropertiesInformation } from '@/Interface'; import { HTTP_REQUEST_NODE_TYPE, + type ICredentialType, type INodeCredentialDescription, type INodeCredentialsDetails, type NodeParameterValueType, } from 'n8n-workflow'; -import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useToast } from '@/composables/useToast'; @@ -31,6 +32,7 @@ import { updateNodeAuthType, } from '@/utils/nodeTypesUtils'; import { + N8nIcon, N8nInput, N8nInputLabel, N8nOption, @@ -67,7 +69,7 @@ const emit = defineEmits<{ const telemetry = useTelemetry(); const i18n = useI18n(); -const NEW_CREDENTIALS_TEXT = `- ${i18n.baseText('nodeCredentials.createNew')} -`; +const NEW_CREDENTIALS_TEXT = i18n.baseText('nodeCredentials.createNew'); const credentialsStore = useCredentialsStore(); const nodeTypesStore = useNodeTypesStore(); @@ -79,7 +81,9 @@ const nodeHelpers = useNodeHelpers(); const toast = useToast(); const subscribedToCredentialType = ref(''); +const filter = ref(''); const listeningForAuthChange = ref(false); +const selectRefs = ref>>([]); const credentialTypesNode = computed(() => credentialTypesNodeDescription.value.map( @@ -344,9 +348,8 @@ function onCredentialSelected( credentialId: string | null | undefined, showAuthOptions = false, ) { - const newCredentialOptionSelected = credentialId === NEW_CREDENTIALS_TEXT; - if (!credentialId || newCredentialOptionSelected) { - createNewCredential(credentialType, newCredentialOptionSelected, showAuthOptions); + if (!credentialId) { + createNewCredential(credentialType, false, showAuthOptions); return; } @@ -501,6 +504,20 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s } return i18n.baseText('nodeCredentials.credentialsLabel'); } + +function setFilter(newFilter = '') { + filter.value = newFilter; +} + +function matches(needle: string, haystack: string) { + return haystack.toLocaleLowerCase().includes(needle); +} + +async function onClickCreateCredential(type: ICredentialType | INodeCredentialDescription) { + selectRefs.value.forEach((select) => select.blur()); + await nextTick(); + createNewCredential(type.name, true, showMixedCredentials(type)); +} - + @@ -576,7 +601,7 @@ function getCredentialsFieldLabel(credentialType: INodeCredentialDescription): s :class="$style.edit" data-test-id="credential-edit-button" > - diff --git a/packages/editor-ui/src/components/ParameterIssues.vue b/packages/editor-ui/src/components/ParameterIssues.vue index 34821c70a69e1..6c023727d46de 100644 --- a/packages/editor-ui/src/components/ParameterIssues.vue +++ b/packages/editor-ui/src/components/ParameterIssues.vue @@ -1,6 +1,7 @@