Merge remote-tracking branch 'origin/master' into pay-2440-bug-credential-testing-is-not-working

This commit is contained in:
Csaba Tuncsik 2025-01-08 13:42:28 +01:00
commit ada03278a2
No known key found for this signature in database
234 changed files with 18651 additions and 1857 deletions

View file

@ -17,7 +17,9 @@ jobs:
build:
name: Install & Build
runs-on: ubuntu-latest
if: github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')
if: |
(github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')) &&
!contains(github.event.pull_request.labels.*.name, 'community')
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable

View file

@ -46,7 +46,14 @@ export function getNodes() {
}
export function getNodeByName(name: string) {
return cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0);
return cy.ifCanvasVersion(
() => cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0),
() => cy.getByTestId('canvas-node').filter(`[data-node-name="${name}"]`).eq(0),
);
}
export function getWorkflowHistoryCloseButton() {
return cy.getByTestId('workflow-history-close-button');
}
export function disableNode(name: string) {

View file

@ -1,15 +1,17 @@
import planData from '../fixtures/Plan_data_opt_in_trial.json';
import {
BannerStack,
MainSidebar,
WorkflowPage,
visitPublicApiPage,
getPublicApiUpgradeCTA,
WorkflowsPage,
} from '../pages';
const NUMBER_OF_AI_CREDITS = 100;
const mainSidebar = new MainSidebar();
const bannerStack = new BannerStack();
const workflowPage = new WorkflowPage();
const workflowsPage = new WorkflowsPage();
describe('Cloud', () => {
before(() => {
@ -22,6 +24,10 @@ describe('Cloud', () => {
cy.overrideSettings({
deployment: { type: 'cloud' },
n8nMetadata: { userId: '1' },
aiCredits: {
enabled: true,
credits: NUMBER_OF_AI_CREDITS,
},
});
cy.intercept('GET', '/rest/admin/cloud-plan', planData).as('getPlanData');
cy.intercept('GET', '/rest/cloud/proxy/user/me', {}).as('getCloudUserInfo');
@ -40,11 +46,11 @@ describe('Cloud', () => {
it('should render trial banner for opt-in cloud user', () => {
visitWorkflowPage();
bannerStack.getters.banner().should('be.visible');
cy.getByTestId('banner-stack').should('be.visible');
mainSidebar.actions.signout();
bannerStack.getters.banner().should('not.be.visible');
cy.getByTestId('banner-stack').should('not.be.visible');
});
});
@ -64,4 +70,66 @@ describe('Cloud', () => {
getPublicApiUpgradeCTA().should('be.visible');
});
});
describe('Easy AI workflow experiment', () => {
it('should not show option to take you to the easy AI workflow if experiment is control', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '026_easy_ai_workflow': 'control' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').should('not.exist');
});
it('should show option to take you to the easy AI workflow if experiment is variant', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '026_easy_ai_workflow': 'variant' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').should('to.exist');
});
it('should show default instructions if free AI credits experiment is control', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '027_free_openai_calls': 'control', '026_easy_ai_workflow': 'variant' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').click();
workflowPage.getters
.stickies()
.eq(0)
.should(($el) => {
expect($el).contains.text('Set up your OpenAI credentials in the OpenAI Model node');
});
});
it('should show updated instructions if free AI credits experiment is variant', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '027_free_openai_calls': 'variant', '026_easy_ai_workflow': 'variant' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').click();
workflowPage.getters
.stickies()
.eq(0)
.should(($el) => {
expect($el).contains.text(
`Claim your free ${NUMBER_OF_AI_CREDITS} OpenAI calls in the OpenAI model node`,
);
});
});
});
});

View file

@ -1,18 +1,14 @@
import { getWorkflowHistoryCloseButton } from '../composables/workflow';
import {
CODE_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
IF_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from '../constants';
import {
WorkflowExecutionsTab,
WorkflowPage as WorkflowPageClass,
WorkflowHistoryPage,
} from '../pages';
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
const workflowPage = new WorkflowPageClass();
const executionsTab = new WorkflowExecutionsTab();
const workflowHistoryPage = new WorkflowHistoryPage();
const createNewWorkflowAndActivate = () => {
workflowPage.actions.visit();
@ -92,7 +88,7 @@ const switchBetweenEditorAndHistory = () => {
cy.wait(['@getVersion']);
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
workflowHistoryPage.getters.workflowHistoryCloseButton().click();
getWorkflowHistoryCloseButton().click();
cy.wait(['@workflowGet']);
cy.wait(1000);
@ -168,7 +164,7 @@ describe('Editor actions should work', () => {
cy.wait(['@getVersion']);
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
workflowHistoryPage.getters.workflowHistoryCloseButton().click();
getWorkflowHistoryCloseButton().click();
cy.wait(['@workflowGet']);
cy.wait(1000);

View file

@ -3,6 +3,7 @@ import * as formStep from '../composables/setup-template-form-step';
import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button';
import TestTemplate1 from '../fixtures/Test_Template_1.json';
import TestTemplate2 from '../fixtures/Test_Template_2.json';
import { clearNotifications } from '../pages/notifications';
import {
clickUseWorkflowButtonByTitle,
visitTemplateCollectionPage,
@ -111,16 +112,19 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
workflowPage.getters.canvasNodes().should('have.length', 3);
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Focus the canvas so the copy to clipboard works
workflowPage.getters.canvasNodes().eq(0).realClick();
workflowPage.actions.hitSelectAll();
workflowPage.actions.hitCopy();
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Check workflow JSON by copying it to clipboard
cy.readClipboard().then((workflowJSON) => {
const workflow = JSON.parse(workflowJSON);
@ -154,6 +158,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
workflowPage.getters.canvasNodes().should('have.length', 3);
@ -176,6 +182,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
getSetupWorkflowCredentialsButton().should('be.visible');
@ -192,6 +200,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
clearNotifications();
setupCredsModal.closeModalFromContinueButton();
setupCredsModal.getWorkflowCredentialsModal().should('not.exist');

View file

@ -1,22 +1,20 @@
import { SettingsPage } from '../pages/settings';
const settingsPage = new SettingsPage();
const url = '/settings';
describe('Admin user', { disableAutoLogin: true }, () => {
it('should see same Settings sub menu items as instance owner', () => {
cy.signinAsOwner();
cy.visit(settingsPage.url);
cy.visit(url);
let ownerMenuItems = 0;
settingsPage.getters.menuItems().then(($el) => {
cy.getByTestId('menu-item').then(($el) => {
ownerMenuItems = $el.length;
});
cy.signout();
cy.signinAsAdmin();
cy.visit(settingsPage.url);
cy.visit(url);
settingsPage.getters.menuItems().should('have.length', ownerMenuItems);
cy.getByTestId('menu-item').should('have.length', ownerMenuItems);
});
});

View file

@ -517,7 +517,7 @@ describe('Node Creator', () => {
const actions = [
'Get ranked documents from vector store',
'Add documents to vector store',
'Retrieve documents for AI processing',
'Retrieve documents for Chain/Tool as Vector Store',
];
nodeCreatorFeature.actions.openNodeCreator();

View file

@ -40,7 +40,7 @@ describe('Subworkflow debugging', () => {
openNode('Execute Workflow with param');
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed and waited on output
@ -64,7 +64,7 @@ describe('Subworkflow debugging', () => {
openNode('Execute Workflow with param2');
getOutputPanelItemsCount().should('not.exist');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed but returned same data as input
@ -109,7 +109,7 @@ describe('Subworkflow debugging', () => {
openNode('Execute Workflow with param');
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed and waited on output
@ -125,7 +125,7 @@ describe('Subworkflow debugging', () => {
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
'include.text',
'Inspect Parent Execution',
'View parent execution',
);
getExecutionPreviewOutputPanelRelatedExecutionLink()

View file

@ -57,7 +57,7 @@ for (const item of $input.all()) {
return
`);
getParameter().get('.cm-lint-marker-error').should('have.length', 6);
getParameter().get('.cm-lintRange-error').should('have.length', 6);
getParameter().contains('itemMatching').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',
@ -81,7 +81,7 @@ $input.item()
return []
`);
getParameter().get('.cm-lint-marker-error').should('have.length', 5);
getParameter().get('.cm-lintRange-error').should('have.length', 5);
getParameter().contains('all').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',

View file

@ -171,9 +171,16 @@ describe('Workflow Actions', () => {
cy.get('#node-creator').should('not.exist');
WorkflowPage.actions.hitSelectAll();
cy.get('.jtk-drag-selected').should('have.length', 2);
WorkflowPage.actions.hitCopy();
successToast().should('exist');
// Both nodes should be copied
cy.window()
.its('navigator.clipboard')
.then((clip) => clip.readText())
.then((text) => {
const copiedWorkflow = JSON.parse(text);
expect(copiedWorkflow.nodes).to.have.length(2);
});
});
it('should paste nodes (both current and old node versions)', () => {
@ -345,7 +352,15 @@ describe('Workflow Actions', () => {
WorkflowPage.actions.hitDeleteAllNodes();
WorkflowPage.getters.canvasNodes().should('have.length', 0);
// Button should be disabled
WorkflowPage.getters.executeWorkflowButton().should('be.disabled');
cy.ifCanvasVersion(
() => {
WorkflowPage.getters.executeWorkflowButton().should('be.disabled');
},
() => {
// In new canvas, button does not exist when there are no nodes
WorkflowPage.getters.executeWorkflowButton().should('not.exist');
},
);
// Keyboard shortcut should not work
WorkflowPage.actions.hitExecuteWorkflow();
successToast().should('not.exist');

View file

@ -19,7 +19,6 @@
"value": {},
"matchingColumns": [],
"schema": [],
"ignoreTypeMismatchErrors": false,
"attemptToConvertTypes": false,
"convertFieldsToString": true
},

View file

@ -12,7 +12,7 @@
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"develop": "cd ..; pnpm dev",
"develop": "cd ..; pnpm dev:e2e:server",
"start": "cd ..; pnpm start"
},
"devDependencies": {

View file

@ -1,9 +0,0 @@
import { BasePage } from './base';
export class BannerStack extends BasePage {
getters = {
banner: () => cy.getByTestId('banner-stack'),
};
actions = {};
}

View file

@ -1,5 +1,13 @@
import type { IE2ETestPage } from '../types';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class BasePage implements IE2ETestPage {
getters = {};

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class CredentialsPage extends BasePage {
url = '/home/credentials';

View file

@ -8,6 +8,14 @@ const AI_ASSISTANT_FEATURE = {
disabledFor: 'control',
};
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class AIAssistant extends BasePage {
url = '/workflows/new';

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class NodeCreator extends BasePage {
url = '/workflow/new';

View file

@ -7,9 +7,7 @@ export * from './settings-users';
export * from './settings-log-streaming';
export * from './sidebar';
export * from './ndv';
export * from './bannerStack';
export * from './workflow-executions-tab';
export * from './signin';
export * from './workflow-history';
export * from './workerView';
export * from './settings-public-api';

View file

@ -3,6 +3,14 @@ import { SigninPage } from './signin';
import { WorkflowsPage } from './workflows';
import { N8N_AUTH_COOKIE } from '../constants';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MfaLoginPage extends BasePage {
url = '/mfa';

View file

@ -1,5 +1,13 @@
import { BasePage } from './../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class ChangePasswordModal extends BasePage {
getters = {
modalContainer: () => cy.getByTestId('changePassword-modal').last(),

View file

@ -2,6 +2,14 @@ import { getCredentialSaveButton, saveCredential } from '../../composables/modal
import { getVisibleSelect } from '../../utils';
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class CredentialsModal extends BasePage {
getters = {
newCredentialModal: () => cy.getByTestId('selectCredential-modal', { timeout: 5000 }),
@ -61,6 +69,7 @@ export class CredentialsModal extends BasePage {
this.getters
.credentialInputs()
.find('input[type=text], input[type=password]')
.filter(':not([readonly])')
.each(($el) => {
cy.wrap($el).type('test');
});

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MessageBox extends BasePage {
getters = {
modal: () => cy.get('.el-message-box', { withinSubject: null }),

View file

@ -1,5 +1,13 @@
import { BasePage } from './../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MfaSetupModal extends BasePage {
getters = {
modalContainer: () => cy.getByTestId('changePassword-modal').last(),

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowSharingModal extends BasePage {
getters = {
modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }),

View file

@ -1,6 +1,14 @@
import { BasePage } from './base';
import { getVisiblePopper, getVisibleSelect } from '../utils';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class NDV extends BasePage {
getters = {
container: () => cy.getByTestId('ndv'),

View file

@ -13,5 +13,10 @@ export const infoToast = () => cy.get('.el-notification:has(.el-notification--in
* Actions
*/
export const clearNotifications = () => {
successToast().find('.el-notification__closeBtn').click({ multiple: true });
const buttons = successToast().find('.el-notification__closeBtn');
buttons.then(($buttons) => {
if ($buttons.length) {
buttons.click({ multiple: true });
}
});
};

View file

@ -1,6 +1,14 @@
import { BasePage } from './base';
import { getVisibleSelect } from '../utils';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SettingsLogStreamingPage extends BasePage {
url = '/settings/log-streaming';

View file

@ -7,6 +7,14 @@ import { MfaSetupModal } from './modals/mfa-setup-modal';
const changePasswordModal = new ChangePasswordModal();
const mfaSetupModal = new MfaSetupModal();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class PersonalSettingsPage extends BasePage {
url = '/settings/personal';

View file

@ -1,9 +0,0 @@
import { BasePage } from './base';
export class SettingsUsagePage extends BasePage {
url = '/settings/usage';
getters = {};
actions = {};
}

View file

@ -9,6 +9,14 @@ const workflowsPage = new WorkflowsPage();
const mainSidebar = new MainSidebar();
const settingsSidebar = new SettingsSidebar();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SettingsUsersPage extends BasePage {
url = '/settings/users';

View file

@ -1,11 +0,0 @@
import { BasePage } from './base';
export class SettingsPage extends BasePage {
url = '/settings';
getters = {
menuItems: () => cy.getByTestId('menu-item'),
};
actions = {};
}

View file

@ -1,6 +1,14 @@
import { BasePage } from '../base';
import { WorkflowsPage } from '../workflows';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MainSidebar extends BasePage {
getters = {
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SettingsSidebar extends BasePage {
getters = {
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),

View file

@ -2,6 +2,14 @@ import { BasePage } from './base';
import { WorkflowsPage } from './workflows';
import { N8N_AUTH_COOKIE } from '../constants';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SigninPage extends BasePage {
url = '/signin';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class TemplatesPage extends BasePage {
url = '/templates';

View file

@ -2,6 +2,14 @@ import { BasePage } from './base';
import Chainable = Cypress.Chainable;
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class VariablesPage extends BasePage {
url = '/variables';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkerViewPage extends BasePage {
url = '/settings/workers';

View file

@ -3,6 +3,14 @@ import { WorkflowPage } from './workflow';
const workflowPage = new WorkflowPage();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowExecutionsTab extends BasePage {
getters = {
executionsTabButton: () => cy.getByTestId('radio-button-executions'),

View file

@ -1,7 +0,0 @@
import { BasePage } from './base';
export class WorkflowHistoryPage extends BasePage {
getters = {
workflowHistoryCloseButton: () => cy.getByTestId('workflow-history-close-button'),
};
}

View file

@ -6,6 +6,15 @@ import { getVisibleSelect } from '../utils';
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
const nodeCreator = new NodeCreator();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowPage extends BasePage {
url = '/workflow/new';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowsPage extends BasePage {
url = '/home/workflows';

View file

@ -18,6 +18,11 @@
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
"dev:fe": "run-p start \"dev:fe:editor --filter=n8n-design-system\"",
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
"dev:e2e": "cd cypress && pnpm run test:e2e:dev",
"dev:e2e:v2": "cd cypress && pnpm run test:e2e:dev:v2",
"dev:e2e:server": "run-p start dev:fe:editor",
"clean": "turbo run clean --parallel",
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
"format": "turbo run format && node scripts/format.mjs",
@ -55,6 +60,7 @@
"lefthook": "^1.7.15",
"nock": "^13.3.2",
"nodemon": "^3.0.1",
"npm-run-all2": "^7.0.2",
"p-limit": "^3.1.0",
"rimraf": "^5.0.1",
"run-script-os": "^1.0.7",

View file

@ -39,13 +39,23 @@ export class TaskRunnersConfig {
@Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE')
maxOldSpaceSize: string = '';
/** How many concurrent tasks can a runner execute at a time */
/**
* How many concurrent tasks can a runner execute at a time
*
* Kept high for backwards compatibility - n8n v2 will reduce this to `5`
*/
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
maxConcurrency: number = 5;
maxConcurrency: number = 10;
/** How long (in seconds) a task is allowed to take for completion, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
/**
* How long (in seconds) a task is allowed to take for completion, else the
* task will be aborted. (In internal mode, the runner will also be
* restarted.) Must be greater than 0.
*
* Kept high for backwards compatibility - n8n v2 will reduce this to `60`
*/
@Env('N8N_RUNNERS_TASK_TIMEOUT')
taskTimeout: number = 60;
taskTimeout: number = 300; // 5 minutes
/** How often (in seconds) the runner must send a heartbeat to the broker, else the task will be aborted. (In internal mode, the runner will also be restarted.) Must be greater than 0. */
@Env('N8N_RUNNERS_HEARTBEAT_INTERVAL')

View file

@ -229,8 +229,8 @@ describe('GlobalConfig', () => {
maxPayload: 1024 * 1024 * 1024,
port: 5679,
maxOldSpaceSize: '',
maxConcurrency: 5,
taskTimeout: 60,
maxConcurrency: 10,
taskTimeout: 300,
heartbeatInterval: 30,
},
sentry: {

View file

@ -131,6 +131,7 @@ export class LmChatGoogleVertex implements INodeType {
const credentials = await this.getCredentials('googleApi');
const privateKey = formatPrivateKey(credentials.privateKey as string);
const email = (credentials.email as string).trim();
const region = credentials.region as string;
const modelName = this.getNodeParameter('modelName', itemIndex) as string;
@ -165,6 +166,7 @@ export class LmChatGoogleVertex implements INodeType {
private_key: privateKey,
},
},
location: region,
model: modelName,
topK: options.topK,
topP: options.topP,

View file

@ -89,14 +89,14 @@ exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] =
"value": "insert",
},
{
"action": "Retrieve documents for AI processing as Vector Store",
"action": "Retrieve documents for Chain/Tool as Vector Store",
"description": "Retrieve documents from vector store to be used as vector store with AI nodes",
"name": "Retrieve Documents (As Vector Store for AI Agent)",
"name": "Retrieve Documents (As Vector Store for Chain/Tool)",
"outputConnectionType": "ai_vectorStore",
"value": "retrieve",
},
{
"action": "Retrieve documents for AI processing as Tool",
"action": "Retrieve documents for AI Agent as Tool",
"description": "Retrieve documents from vector store to be used as tool with AI nodes",
"name": "Retrieve Documents (As Tool for AI Agent)",
"outputConnectionType": "ai_tool",

View file

@ -111,17 +111,17 @@ function getOperationModeOptions(args: VectorStoreNodeConstructorArgs): INodePro
action: 'Add documents to vector store',
},
{
name: 'Retrieve Documents (As Vector Store for AI Agent)',
name: 'Retrieve Documents (As Vector Store for Chain/Tool)',
value: 'retrieve',
description: 'Retrieve documents from vector store to be used as vector store with AI nodes',
action: 'Retrieve documents for AI processing as Vector Store',
action: 'Retrieve documents for Chain/Tool as Vector Store',
outputConnectionType: NodeConnectionType.AiVectorStore,
},
{
name: 'Retrieve Documents (As Tool for AI Agent)',
value: 'retrieve-as-tool',
description: 'Retrieve documents from vector store to be used as tool with AI nodes',
action: 'Retrieve documents for AI processing as Tool',
action: 'Retrieve documents for AI Agent as Tool',
outputConnectionType: NodeConnectionType.AiTool,
},
{

View file

@ -23,8 +23,13 @@ export class BaseRunnerConfig {
@Env('N8N_RUNNERS_MAX_PAYLOAD')
maxPayloadSize: number = 1024 * 1024 * 1024;
/**
* How many concurrent tasks can a runner execute at a time
*
* Kept high for backwards compatibility - n8n v2 will reduce this to `5`
*/
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
maxConcurrency: number = 5;
maxConcurrency: number = 10;
/**
* How long (in seconds) a runner may be idle for before exit. Intended
@ -37,8 +42,15 @@ export class BaseRunnerConfig {
@Env('GENERIC_TIMEZONE')
timezone: string = 'America/New_York';
/**
* How long (in seconds) a task is allowed to take for completion, else the
* task will be aborted. (In internal mode, the runner will also be
* restarted.) Must be greater than 0.
*
* Kept high for backwards compatibility - n8n v2 will reduce this to `60`
*/
@Env('N8N_RUNNERS_TASK_TIMEOUT')
taskTimeout: number = 60;
taskTimeout: number = 300; // 5 minutes
@Nested
healthcheckServer!: HealthcheckServerConfig;

View file

@ -195,4 +195,3 @@ export const WsStatusCodes = {
} as const;
export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';

View file

@ -6,10 +6,11 @@ import {
} from '@n8n/api-types';
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import { Response } from 'express';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
import { strict as assert } from 'node:assert';
import { WritableStream } from 'node:stream/web';
import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
import { CredentialsService } from '@/credentials/credentials.service';
import { Body, Post, RestController } from '@/decorators';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';

View file

@ -35,4 +35,23 @@ export class TestRun extends WithTimestampsAndStringId {
@Column(jsonColumnType, { nullable: true })
metrics: AggregatedTestRunMetrics;
/**
* Total number of the test cases, matching the filter condition of the test definition (specified annotationTag)
*/
@Column('integer', { nullable: true })
totalCases: number;
/**
* Number of test cases that passed (evaluation workflow was executed successfully)
*/
@Column('integer', { nullable: true })
passedCases: number;
/**
* Number of failed test cases
* (any unexpected exception happened during the execution or evaluation workflow ended with an error)
*/
@Column('integer', { nullable: true })
failedCases: number;
}

View file

@ -0,0 +1,31 @@
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
const columns = ['totalCases', 'passedCases', 'failedCases'] as const;
export class AddStatsColumnsToTestRun1736172058779 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const columnNames = columns.map((name) => escape.columnName(name));
// Values can be NULL only if the test run is new, otherwise they must be non-negative integers.
// Test run might be cancelled or interrupted by unexpected error at any moment, so values can be either NULL or non-negative integers.
for (const name of columnNames) {
await runQuery(`ALTER TABLE ${tableName} ADD COLUMN ${name} INT CHECK(
CASE
WHEN status = 'new' THEN ${name} IS NULL
WHEN status in ('cancelled', 'error') THEN ${name} IS NULL OR ${name} >= 0
ELSE ${name} >= 0
END
)`);
}
}
async down({ escape, runQuery }: MigrationContext) {
const tableName = escape.tableName('test_run');
const columnNames = columns.map((name) => escape.columnName(name));
for (const name of columnNames) {
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${name}`);
}
}
}

View file

@ -76,6 +76,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
@ -154,4 +155,5 @@ export const mysqlMigrations: Migration[] = [
AddMockedNodesColumnToTestDefinition1733133775640,
AddManagedColumnToCredentialsTable1734479635324,
AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779,
];

View file

@ -76,6 +76,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
export const postgresMigrations: Migration[] = [
InitialMigration1587669153312,
@ -154,4 +155,5 @@ export const postgresMigrations: Migration[] = [
AddMockedNodesColumnToTestDefinition1733133775640,
AddManagedColumnToCredentialsTable1734479635324,
AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779,
];

View file

@ -73,6 +73,7 @@ import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-Crea
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@ -148,6 +149,7 @@ const sqliteMigrations: Migration[] = [
AddMockedNodesColumnToTestDefinition1733133775640,
AddManagedColumnToCredentialsTable1734479635324,
AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779,
];
export { sqliteMigrations };

View file

@ -21,14 +21,28 @@ export class TestRunRepository extends Repository<TestRun> {
return await this.save(testRun);
}
async markAsRunning(id: string) {
return await this.update(id, { status: 'running', runAt: new Date() });
async markAsRunning(id: string, totalCases: number) {
return await this.update(id, {
status: 'running',
runAt: new Date(),
totalCases,
passedCases: 0,
failedCases: 0,
});
}
async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) {
return await this.update(id, { status: 'completed', completedAt: new Date(), metrics });
}
async incrementPassed(id: string) {
return await this.increment({ id }, 'passedCases', 1);
}
async incrementFailed(id: string) {
return await this.increment({ id }, 'failedCases', 1);
}
async getMany(testDefinitionId: string, options: ListQuery.Options) {
const findManyOptions: FindManyOptions<TestRun> = {
where: { testDefinition: { id: testDefinitionId } },

View file

@ -2,7 +2,8 @@ import type { SelectQueryBuilder } from '@n8n/typeorm';
import { stringify } from 'flatted';
import { readFileSync } from 'fs';
import { mock, mockDeep } from 'jest-mock-extended';
import type { GenericValue, IRun } from 'n8n-workflow';
import type { ErrorReporter } from 'n8n-core';
import type { ExecutionError, GenericValue, IRun } from 'n8n-workflow';
import path from 'path';
import type { ActiveExecutions } from '@/active-executions';
@ -90,6 +91,16 @@ function mockExecutionData() {
});
}
function mockErrorExecutionData() {
return mock<IRun>({
data: {
resultData: {
error: mock<ExecutionError>(),
},
},
});
}
function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
return mock<IRun>({
data: {
@ -110,6 +121,9 @@ function mockEvaluationExecutionData(metrics: Record<string, GenericValue>) {
},
],
},
// error is an optional prop, but jest-mock-extended will mock it by default,
// which affects the code logic. So, we need to explicitly set it to undefined.
error: undefined,
},
},
});
@ -156,6 +170,8 @@ describe('TestRunnerService', () => {
testRunRepository.createTestRun.mockClear();
testRunRepository.markAsRunning.mockClear();
testRunRepository.markAsCompleted.mockClear();
testRunRepository.incrementFailed.mockClear();
testRunRepository.incrementPassed.mockClear();
});
test('should create an instance of TestRunnerService', async () => {
@ -167,6 +183,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
expect(testRunnerService).toBeInstanceOf(TestRunnerService);
@ -181,6 +198,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -218,6 +236,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -298,12 +317,185 @@ describe('TestRunnerService', () => {
// Check Test Run status was updated correctly
expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1);
expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1);
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id');
expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id', expect.any(Number));
expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1);
expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', {
metric1: 0.75,
metric2: 0,
});
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2);
expect(testRunRepository.incrementFailed).not.toHaveBeenCalled();
});
test('should properly count passed and failed executions', async () => {
const testRunnerService = new TestRunnerService(
workflowRepository,
workflowRunner,
executionRepository,
activeExecutions,
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
id: 'workflow-under-test-id',
...wfUnderTestJson,
});
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
id: 'evaluation-workflow-id',
...wfEvaluationJson,
});
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
// Mock executions of workflow under test
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id')
.mockResolvedValue(mockExecutionData());
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-3')
.mockResolvedValue(mockExecutionData());
// Mock executions of evaluation workflow
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-2')
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-4')
.mockRejectedValue(new Error('Some error'));
await testRunnerService.runTest(
mock<User>(),
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [],
}),
);
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
});
test('should properly count failed test executions', async () => {
const testRunnerService = new TestRunnerService(
workflowRepository,
workflowRunner,
executionRepository,
activeExecutions,
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
id: 'workflow-under-test-id',
...wfUnderTestJson,
});
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
id: 'evaluation-workflow-id',
...wfEvaluationJson,
});
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
// Mock executions of workflow under test
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id')
.mockResolvedValue(mockExecutionData());
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-3')
.mockResolvedValue(mockErrorExecutionData());
// Mock executions of evaluation workflow
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-2')
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
await testRunnerService.runTest(
mock<User>(),
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [],
}),
);
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
});
test('should properly count failed evaluations', async () => {
const testRunnerService = new TestRunnerService(
workflowRepository,
workflowRunner,
executionRepository,
activeExecutions,
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
id: 'workflow-under-test-id',
...wfUnderTestJson,
});
workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({
id: 'evaluation-workflow-id',
...wfEvaluationJson,
});
workflowRunner.run.mockResolvedValueOnce('some-execution-id');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-2');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-3');
workflowRunner.run.mockResolvedValueOnce('some-execution-id-4');
// Mock executions of workflow under test
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id')
.mockResolvedValue(mockExecutionData());
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-3')
.mockResolvedValue(mockExecutionData());
// Mock executions of evaluation workflow
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-2')
.mockResolvedValue(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }));
activeExecutions.getPostExecutePromise
.calledWith('some-execution-id-4')
.mockResolvedValue(mockErrorExecutionData());
await testRunnerService.runTest(
mock<User>(),
mock<TestDefinition>({
workflowId: 'workflow-under-test-id',
evaluationWorkflowId: 'evaluation-workflow-id',
mockedNodes: [],
}),
);
expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(1);
expect(testRunRepository.incrementFailed).toHaveBeenCalledTimes(1);
});
test('should specify correct start nodes when running workflow under test', async () => {
@ -315,6 +507,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({
@ -388,6 +581,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
const startNodesData = (testRunnerService as any).getStartNodesData(
@ -412,6 +606,7 @@ describe('TestRunnerService', () => {
testRunRepository,
testMetricRepository,
mockNodeTypes,
mock<ErrorReporter>(),
);
const startNodesData = (testRunnerService as any).getStartNodesData(

View file

@ -1,5 +1,6 @@
import { Service } from '@n8n/di';
import { parse } from 'flatted';
import { ErrorReporter } from 'n8n-core';
import { NodeConnectionType, Workflow } from 'n8n-workflow';
import type {
IDataObject,
@ -45,6 +46,7 @@ export class TestRunnerService {
private readonly testRunRepository: TestRunRepository,
private readonly testMetricRepository: TestMetricRepository,
private readonly nodeTypes: NodeTypes,
private readonly errorReporter: ErrorReporter,
) {}
/**
@ -134,6 +136,7 @@ export class TestRunnerService {
evaluationWorkflow: WorkflowEntity,
expectedData: IRunData,
actualData: IRunData,
testRunId?: string,
) {
// Prepare the evaluation wf input data.
// Provide both the expected data and the actual data
@ -146,7 +149,13 @@ export class TestRunnerService {
// Prepare the data to run the evaluation workflow
const data = await getRunData(evaluationWorkflow, [evaluationInputData]);
// FIXME: This is a hack to add the testRunId to the evaluation workflow execution data
// So that we can fetch all execution runs for a test run
if (testRunId && data.executionData) {
data.executionData.resultData.metadata = {
testRunId,
};
}
data.executionMode = 'evaluation';
// Trigger the evaluation workflow
@ -223,52 +232,66 @@ export class TestRunnerService {
// 2. Run over all the test cases
await this.testRunRepository.markAsRunning(testRun.id);
await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length);
// Object to collect the results of the evaluation workflow executions
const metrics = new EvaluationMetrics(testMetricNames);
for (const { id: pastExecutionId } of pastExecutions) {
// Fetch past execution with data
const pastExecution = await this.executionRepository.findOne({
where: { id: pastExecutionId },
relations: ['executionData', 'metadata'],
});
assert(pastExecution, 'Execution not found');
try {
// Fetch past execution with data
const pastExecution = await this.executionRepository.findOne({
where: { id: pastExecutionId },
relations: ['executionData', 'metadata'],
});
assert(pastExecution, 'Execution not found');
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
const executionData = parse(pastExecution.executionData.data) as IRunExecutionData;
// Run the test case and wait for it to finish
const testCaseExecution = await this.runTestCase(
workflow,
executionData,
pastExecution.executionData.workflowData,
test.mockedNodes,
user.id,
);
// Run the test case and wait for it to finish
const testCaseExecution = await this.runTestCase(
workflow,
executionData,
pastExecution.executionData.workflowData,
test.mockedNodes,
user.id,
);
// In case of a permission check issue, the test case execution will be undefined.
// Skip them and continue with the next test case
if (!testCaseExecution) {
continue;
// In case of a permission check issue, the test case execution will be undefined.
// Skip them, increment the failed count and continue with the next test case
if (!testCaseExecution) {
await this.testRunRepository.incrementFailed(testRun.id);
continue;
}
// Collect the results of the test case execution
const testCaseRunData = testCaseExecution.data.resultData.runData;
// Get the original runData from the test case execution data
const originalRunData = executionData.resultData.runData;
// Run the evaluation workflow with the original and new run data
const evalExecution = await this.runTestCaseEvaluation(
evaluationWorkflow,
originalRunData,
testCaseRunData,
testRun.id,
);
assert(evalExecution);
metrics.addResults(this.extractEvaluationResult(evalExecution));
if (evalExecution.data.resultData.error) {
await this.testRunRepository.incrementFailed(testRun.id);
} else {
await this.testRunRepository.incrementPassed(testRun.id);
}
} catch (e) {
// In case of an unexpected error, increment the failed count and continue with the next test case
await this.testRunRepository.incrementFailed(testRun.id);
this.errorReporter.error(e);
}
// Collect the results of the test case execution
const testCaseRunData = testCaseExecution.data.resultData.runData;
// Get the original runData from the test case execution data
const originalRunData = executionData.resultData.runData;
// Run the evaluation workflow with the original and new run data
const evalExecution = await this.runTestCaseEvaluation(
evaluationWorkflow,
originalRunData,
testCaseRunData,
);
assert(evalExecution);
// Extract the output of the last node executed in the evaluation workflow
metrics.addResults(this.extractEvaluationResult(evalExecution));
}
const aggregatedMetrics = metrics.getAggregatedMetrics();

View file

@ -5,7 +5,9 @@ import type { INode, INodesGraphResult } from 'n8n-workflow';
import { NodeApiError, TelemetryHelpers, type IRun, type IWorkflowBase } from 'n8n-workflow';
import { N8N_VERSION } from '@/constants';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import type { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import type { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import type { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import type { WorkflowRepository } from '@/databases/repositories/workflow.repository';
@ -52,6 +54,7 @@ describe('TelemetryEventRelay', () => {
const nodeTypes = mock<NodeTypes>();
const sharedWorkflowRepository = mock<SharedWorkflowRepository>();
const projectRelationRepository = mock<ProjectRelationRepository>();
const credentialsRepository = mock<CredentialsRepository>();
const eventService = new EventService();
let telemetryEventRelay: TelemetryEventRelay;
@ -67,6 +70,7 @@ describe('TelemetryEventRelay', () => {
nodeTypes,
sharedWorkflowRepository,
projectRelationRepository,
credentialsRepository,
);
await telemetryEventRelay.init();
@ -90,6 +94,7 @@ describe('TelemetryEventRelay', () => {
nodeTypes,
sharedWorkflowRepository,
projectRelationRepository,
credentialsRepository,
);
// @ts-expect-error Private method
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
@ -112,6 +117,7 @@ describe('TelemetryEventRelay', () => {
nodeTypes,
sharedWorkflowRepository,
projectRelationRepository,
credentialsRepository,
);
// @ts-expect-error Private method
const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners');
@ -1197,6 +1203,9 @@ describe('TelemetryEventRelay', () => {
it('should call telemetry.track when manual node execution finished', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: false }),
);
const runData = {
status: 'error',
@ -1276,6 +1285,8 @@ describe('TelemetryEventRelay', () => {
error_node_id: '1',
node_id: '1',
node_type: 'n8n-nodes-base.jira',
is_managed: false,
credential_type: null,
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
@ -1498,5 +1509,187 @@ describe('TelemetryEventRelay', () => {
}),
);
});
it('should call telemetry.track when manual node execution finished with is_managed and credential_type properties', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
);
const runData = {
status: 'error',
mode: 'manual',
data: {
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'Jira',
type: 'n8n-nodes-base.jira',
parameters: {},
position: [100, 200],
},
{
message: 'Error message',
description: 'Incorrect API key provided',
httpCode: '401',
stack: '',
},
{
message: 'Error message',
description: 'Error description',
level: 'warning',
functionality: 'regular',
},
),
},
},
} as unknown as IRun;
const nodeGraph: INodesGraphResult = {
nodeGraph: { node_types: [], node_connections: [], webhookNodeNames: [] },
nameIndices: {
Jira: '1',
OpenAI: '1',
},
} as unknown as INodesGraphResult;
jest.spyOn(TelemetryHelpers, 'generateNodesGraph').mockImplementation(() => nodeGraph);
jest
.spyOn(TelemetryHelpers, 'getNodeTypeForName')
.mockImplementation(
() => ({ type: 'n8n-nodes-base.jira', version: 1, name: 'Jira' }) as unknown as INode,
);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({
id: 'nhu-l8E4hX',
});
expect(telemetry.track).toHaveBeenCalledWith(
'Manual node exec finished',
expect.objectContaining({
webhook_domain: null,
user_id: 'user123',
workflow_id: 'workflow123',
status: 'error',
executionStatus: 'error',
sharing_role: 'sharee',
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
error_node_id: '1',
node_id: '1',
node_type: 'n8n-nodes-base.jira',
is_managed: true,
credential_type: 'openAiApi',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
}),
);
expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith(
expect.objectContaining({
workflow_id: 'workflow123',
success: false,
is_manual: true,
execution_mode: 'manual',
version_cli: N8N_VERSION,
error_message: 'Error message',
error_node_type: 'n8n-nodes-base.jira',
node_graph_string: JSON.stringify(nodeGraph.nodeGraph),
error_node_id: '1',
}),
);
});
it('should call telemetry.track when user ran out of free AI credits', async () => {
sharedWorkflowRepository.findSharingRole.mockResolvedValue('workflow:editor');
credentialsRepository.findOneBy.mockResolvedValue(
mock<CredentialsEntity>({ type: 'openAiApi', isManaged: true }),
);
const runData = {
status: 'error',
mode: 'trigger',
data: {
startData: {
destinationNode: 'OpenAI',
runNodeFilter: ['OpenAI'],
},
executionData: {
nodeExecutionStack: [{ node: { credentials: { openAiApi: { id: 'nhu-l8E4hX' } } } }],
},
resultData: {
runData: {},
lastNodeExecuted: 'OpenAI',
error: new NodeApiError(
{
id: '1',
typeVersion: 1,
name: 'OpenAI',
type: 'n8n-nodes-base.openAi',
parameters: {},
position: [100, 200],
},
{
message: `400 - ${JSON.stringify({
error: {
message: 'error message',
type: 'error_type',
code: 200,
},
})}`,
error: {
message: 'error message',
type: 'error_type',
code: 200,
},
},
{
httpCode: '400',
},
),
},
},
} as unknown as IRun;
jest
.spyOn(TelemetryHelpers, 'userInInstanceRanOutOfFreeAiCredits')
.mockImplementation(() => true);
const event: RelayEventMap['workflow-post-execute'] = {
workflow: mockWorkflowBase,
executionId: 'execution123',
userId: 'user123',
runData,
};
eventService.emit('workflow-post-execute', event);
await flushPromises();
expect(telemetry.track).toHaveBeenCalledWith('User ran out of free AI credits');
});
});
});

View file

@ -9,6 +9,7 @@ import { get as pslGet } from 'psl';
import config from '@/config';
import { N8N_VERSION } from '@/constants';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
@ -34,6 +35,7 @@ export class TelemetryEventRelay extends EventRelay {
private readonly nodeTypes: NodeTypes,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly credentialsRepository: CredentialsRepository,
) {
super(eventService);
}
@ -632,6 +634,10 @@ export class TelemetryEventRelay extends EventRelay {
let nodeGraphResult: INodesGraphResult | null = null;
if (!telemetryProperties.success && runData?.data.resultData.error) {
if (TelemetryHelpers.userInInstanceRanOutOfFreeAiCredits(runData)) {
this.telemetry.track('User ran out of free AI credits');
}
telemetryProperties.error_message = runData?.data.resultData.error.message;
let errorNodeName =
'node' in runData?.data.resultData.error
@ -693,6 +699,8 @@ export class TelemetryEventRelay extends EventRelay {
error_node_id: telemetryProperties.error_node_id as string,
webhook_domain: null,
sharing_role: userRole,
credential_type: null,
is_managed: false,
};
if (!manualExecEventProperties.node_graph_string) {
@ -703,7 +711,18 @@ export class TelemetryEventRelay extends EventRelay {
}
if (runData.data.startData?.destinationNode) {
const telemetryPayload = {
const credentialsData = TelemetryHelpers.extractLastExecutedNodeCredentialData(runData);
if (credentialsData) {
manualExecEventProperties.credential_type = credentialsData.credentialType;
const credential = await this.credentialsRepository.findOneBy({
id: credentialsData.credentialId,
});
if (credential) {
manualExecEventProperties.is_managed = credential.isManaged;
}
}
const telemetryPayload: ITelemetryTrackProperties = {
...manualExecEventProperties,
node_type: TelemetryHelpers.getNodeTypeForName(
workflow,

View file

@ -2,6 +2,7 @@ import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import compression from 'compression';
import express from 'express';
import { rateLimit as expressRateLimit } from 'express-rate-limit';
import { Logger } from 'n8n-core';
import * as a from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
@ -147,8 +148,16 @@ export class TaskRunnerServer {
}
private configureRoutes() {
const createRateLimiter = () =>
expressRateLimit({
windowMs: 1000,
limit: 5,
message: { message: 'Too many requests' },
});
this.app.use(
this.upgradeEndpoint,
createRateLimiter(),
// eslint-disable-next-line @typescript-eslint/unbound-method
this.taskRunnerAuthController.authMiddleware,
(req: TaskRunnerServerInitRequest, res: TaskRunnerServerInitResponse) =>
@ -158,6 +167,7 @@ export class TaskRunnerServer {
const authEndpoint = `${this.getEndpointBasePath()}/auth`;
this.app.post(
authEndpoint,
createRateLimiter(),
send(async (req) => await this.taskRunnerAuthController.createGrantToken(req)),
);

View file

@ -147,6 +147,12 @@ export class WorkflowExecutionService {
triggerToStartFrom,
};
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
if (pinnedTrigger && !hasRunData(pinnedTrigger)) {
data.startNodes = [{ name: pinnedTrigger.name, sourceData: null }];
}
/**
* Historically, manual executions in scaling mode ran in the main process,
* so some execution details were never persisted in the database.
@ -160,7 +166,7 @@ export class WorkflowExecutionService {
) {
data.executionData = {
startData: {
startNodes,
startNodes: data.startNodes,
destinationNode,
},
resultData: {
@ -176,12 +182,6 @@ export class WorkflowExecutionService {
};
}
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
if (pinnedTrigger && !hasRunData(pinnedTrigger)) {
data.startNodes = [{ name: pinnedTrigger.name, sourceData: null }];
}
const executionId = await this.workflowRunner.run(data);
return {

View file

@ -1,8 +1,9 @@
import { Container } from '@n8n/di';
import { randomUUID } from 'crypto';
import { mock } from 'jest-mock-extended';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
import { FREE_AI_CREDITS_CREDENTIAL_NAME } from '@/constants';
import type { Project } from '@/databases/entities/project';
import type { User } from '@/databases/entities/user';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';

View file

@ -93,7 +93,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => {
const testRunRepository = Container.get(TestRunRepository);
const testRun1 = await testRunRepository.createTestRun(testDefinition.id);
// Mark as running just to make a slight delay between the runs
await testRunRepository.markAsRunning(testRun1.id);
await testRunRepository.markAsRunning(testRun1.id, 10);
const testRun2 = await testRunRepository.createTestRun(testDefinition.id);
// Fetch the first page

View file

@ -19,4 +19,26 @@ describe('TaskRunnerServer', () => {
await agent.get('/healthz').expect(200);
});
});
describe('/runners/_ws', () => {
it('should return 429 when too many requests are made', async () => {
await agent.post('/runners/_ws').send({}).expect(401);
await agent.post('/runners/_ws').send({}).expect(401);
await agent.post('/runners/_ws').send({}).expect(401);
await agent.post('/runners/_ws').send({}).expect(401);
await agent.post('/runners/_ws').send({}).expect(401);
await agent.post('/runners/_ws').send({}).expect(429);
});
});
describe('/runners/auth', () => {
it('should return 429 when too many requests are made', async () => {
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(403);
await agent.post('/runners/auth').send({ token: 'invalid' }).expect(429);
});
});
});

View file

@ -61,11 +61,7 @@ const validateResourceMapperValue = (
});
if (!validationResult.valid) {
if (!resourceMapperField.ignoreTypeMismatchErrors) {
return { ...validationResult, fieldName: key };
} else {
paramValues[key] = resolvedValue;
}
return { ...validationResult, fieldName: key };
} else {
// If it's valid, set the casted value
paramValues[key] = validationResult.newValue;

View file

@ -11,7 +11,7 @@
"test": "vitest run",
"test:dev": "vitest",
"build:storybook": "storybook build",
"storybook": "storybook dev -p 6006",
"storybook": "storybook dev -p 6006 --no-open",
"chromatic": "chromatic",
"format": "biome format --write . && prettier --write . --ignore-path ../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../.prettierignore",

View file

@ -31,6 +31,10 @@ const props = defineProps({
multiple: {
type: Boolean,
},
multipleLimit: {
type: Number,
default: 0,
},
filterMethod: {
type: Function,
},
@ -120,6 +124,7 @@ defineExpose({
<ElSelect
v-bind="{ ...$props, ...listeners }"
ref="innerSelect"
:multiple-limit="props.multipleLimit"
:model-value="props.modelValue ?? undefined"
:size="computedSize"
:popper-class="props.popperClass"

View file

@ -16,6 +16,7 @@
--prim-gray-490: hsl(var(--prim-gray-h), 3%, 51%);
--prim-gray-420: hsl(var(--prim-gray-h), 4%, 58%);
--prim-gray-320: hsl(var(--prim-gray-h), 10%, 68%);
--prim-gray-320-alpha-010: hsla(var(--prim-gray-h), 10%, 68%, 0.1);
--prim-gray-200: hsl(var(--prim-gray-h), 18%, 80%);
--prim-gray-120: hsl(var(--prim-gray-h), 25%, 88%);
--prim-gray-70: hsl(var(--prim-gray-h), 32%, 93%);

View file

@ -139,12 +139,20 @@
--color-infobox-examples-border-color: var(--prim-gray-670);
// Code
--color-code-tags-string: var(--prim-color-alt-f-tint-150);
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100);
--color-code-tags-keyword: var(--prim-color-alt-g-tint-150);
--color-code-tags-operator: var(--prim-color-alt-h);
--color-code-tags-variable: var(--prim-color-primary-tint-100);
--color-code-tags-definition: var(--prim-color-alt-e);
--color-code-tags-string: #9ecbff;
--color-code-tags-regex: #9ecbff;
--color-code-tags-primitive: #79b8ff;
--color-code-tags-keyword: #f97583;
--color-code-tags-variable: #79b8ff;
--color-code-tags-parameter: #e1e4e8;
--color-code-tags-function: #b392f0;
--color-code-tags-constant: #79b8ff;
--color-code-tags-property: #79b8ff;
--color-code-tags-type: #b392f0;
--color-code-tags-class: #b392f0;
--color-code-tags-heading: #79b8ff;
--color-code-tags-invalid: #f97583;
--color-code-tags-comment: #6a737d;
--color-json-default: var(--prim-color-secondary-tint-200);
--color-json-null: var(--color-danger);
--color-json-boolean: var(--prim-color-alt-a);
@ -155,15 +163,18 @@
--color-json-brackets-hover: var(--prim-color-alt-e);
--color-json-line: var(--prim-gray-200);
--color-json-highlight: var(--color-background-base);
--color-code-background: var(--prim-gray-800);
--color-code-background: var(--prim-gray-820);
--color-code-background-readonly: var(--prim-gray-740);
--color-code-lineHighlight: var(--prim-gray-740);
--color-code-lineHighlight: var(--prim-gray-320-alpha-010);
--color-code-foreground: var(--prim-gray-70);
--color-code-caret: var(--prim-gray-10);
--color-code-selection: var(--prim-color-alt-e-alpha-04);
--color-code-gutterBackground: var(--prim-gray-670);
--color-code-gutterForeground: var(--prim-gray-320);
--color-code-tags-comment: var(--prim-gray-200);
--color-code-selection: #3392ff44;
--color-code-selection-highlight: #17e5e633;
--color-code-gutter-background: var(--prim-gray-820);
--color-code-gutter-foreground: var(--prim-gray-320);
--color-code-gutter-foreground-active: var(--prim-gray-10);
--color-code-indentation-marker: var(--prim-gray-740);
--color-code-indentation-marker-active: var(--prim-gray-670);
--color-line-break: var(--prim-gray-420);
--color-code-line-break: var(--prim-color-secondary-tint-100);

View file

@ -183,12 +183,20 @@
--color-infobox-examples-border-color: var(--color-foreground-light);
// Code
--color-code-tags-string: var(--prim-color-alt-f);
--color-code-tags-primitive: var(--prim-color-alt-b-shade-100);
--color-code-tags-keyword: var(--prim-color-alt-g);
--color-code-tags-operator: var(--prim-color-alt-h);
--color-code-tags-variable: var(--prim-color-alt-c-shade-100);
--color-code-tags-definition: var(--prim-color-alt-e-shade-150);
--color-code-tags-string: #032f62;
--color-code-tags-regex: #032f62;
--color-code-tags-primitive: #005cc5;
--color-code-tags-keyword: #d73a49;
--color-code-tags-variable: #005cc5;
--color-code-tags-parameter: #24292e;
--color-code-tags-function: #6f42c1;
--color-code-tags-constant: #005cc5;
--color-code-tags-property: #005cc5;
--color-code-tags-type: #005cc5;
--color-code-tags-class: #6f42c1;
--color-code-tags-heading: #005cc5;
--color-code-tags-invalid: #cb2431;
--color-code-tags-comment: #6a737d;
--color-json-default: var(--prim-color-secondary-shade-100);
--color-json-null: var(--prim-color-alt-c);
--color-json-boolean: var(--prim-color-alt-a);
@ -201,13 +209,16 @@
--color-json-highlight: var(--prim-gray-70);
--color-code-background: var(--prim-gray-0);
--color-code-background-readonly: var(--prim-gray-40);
--color-code-lineHighlight: var(--prim-gray-40);
--color-code-lineHighlight: var(--prim-gray-320-alpha-010);
--color-code-foreground: var(--prim-gray-670);
--color-code-caret: var(--prim-gray-420);
--color-code-selection: var(--prim-gray-120);
--color-code-gutterBackground: var(--prim-gray-0);
--color-code-gutterForeground: var(--prim-gray-320);
--color-code-tags-comment: var(--prim-gray-420);
--color-code-caret: var(--prim-gray-820);
--color-code-selection: #0366d625;
--color-code-selection-highlight: #34d05840;
--color-code-gutter-background: var(--prim-gray-0);
--color-code-gutter-foreground: var(--prim-gray-320);
--color-code-gutter-foreground-active: var(--prim-gray-670);
--color-code-indentation-marker: var(--prim-gray-70);
--color-code-indentation-marker-active: var(--prim-gray-200);
--color-line-break: var(--prim-gray-320);
--color-code-line-break: var(--prim-color-secondary-tint-200);

View file

@ -25,6 +25,7 @@
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language": "^6.10.1",
"@codemirror/lint": "^6.8.0",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.26.3",
"@fontsource/open-sans": "^4.5.0",
@ -39,6 +40,8 @@
"@n8n/codemirror-lang": "workspace:*",
"@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*",
"@replit/codemirror-indentation-markers": "^6.5.3",
"@typescript/vfs": "^1.6.0",
"@sentry/vue": "catalog:frontend",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.2",
@ -52,6 +55,7 @@
"change-case": "^5.4.4",
"chart.js": "^4.4.0",
"codemirror-lang-html-n8n": "^1.0.0",
"comlink": "^4.4.1",
"dateformat": "^3.0.3",
"email-providers": "^2.0.1",
"esprima-next": "5.8.4",
@ -70,6 +74,7 @@
"qrcode.vue": "^3.3.4",
"stream-browserify": "^3.0.0",
"timeago.js": "^4.0.2",
"typescript": "^5.5.2",
"uuid": "catalog:",
"v3-infinite-loading": "^1.2.2",
"vue": "catalog:frontend",

View file

@ -1,5 +1,6 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import { makeRestApiRequest, request } from '@/utils/apiUtils';
export interface TestDefinitionRecord {
id: string;
name: string;
@ -9,7 +10,10 @@ export interface TestDefinitionRecord {
description?: string | null;
updatedAt?: string;
createdAt?: string;
annotationTag?: string | null;
mockedNodes?: Array<{ name: string }>;
}
interface CreateTestDefinitionParams {
name: string;
workflowId: string;
@ -21,31 +25,63 @@ export interface UpdateTestDefinitionParams {
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
description?: string | null;
mockedNodes?: Array<{ name: string }>;
}
export interface UpdateTestResponse {
createdAt: string;
updatedAt: string;
id: string;
name: string;
workflowId: string;
description: string | null;
annotationTag: string | null;
evaluationWorkflowId: string | null;
annotationTagId: string | null;
description?: string | null;
annotationTag?: string | null;
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
}
export interface TestRunRecord {
id: string;
testDefinitionId: string;
status: 'new' | 'running' | 'completed' | 'error';
metrics?: Record<string, number>;
createdAt: string;
updatedAt: string;
runAt: string;
completedAt: string;
}
interface GetTestRunParams {
testDefinitionId: string;
runId: string;
}
interface DeleteTestRunParams {
testDefinitionId: string;
runId: string;
}
const endpoint = '/evaluation/test-definitions';
const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) =>
`${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`;
export async function getTestDefinitions(context: IRestApiContext) {
export async function getTestDefinitions(
context: IRestApiContext,
params?: { workflowId?: string },
) {
let url = endpoint;
if (params?.workflowId) {
url += `?filter=${JSON.stringify({ workflowId: params.workflowId })}`;
}
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
context,
'GET',
endpoint,
url,
);
}
export async function getTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ id: string }>(context, 'GET', `${endpoint}/${id}`);
return await makeRestApiRequest<TestDefinitionRecord>(context, 'GET', `${endpoint}/${id}`);
}
export async function createTestDefinition(
@ -71,3 +107,125 @@ export async function updateTestDefinition(
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
}
// Metrics
export interface TestMetricRecord {
id: string;
name: string;
testDefinitionId: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateTestMetricParams {
testDefinitionId: string;
name: string;
}
export interface UpdateTestMetricParams {
name: string;
id: string;
testDefinitionId: string;
}
export interface DeleteTestMetricParams {
testDefinitionId: string;
id: string;
}
export const getTestMetrics = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestMetricRecord[]>(
context,
'GET',
getMetricsEndpoint(testDefinitionId),
);
};
export const getTestMetric = async (
context: IRestApiContext,
testDefinitionId: string,
id: string,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'GET',
getMetricsEndpoint(testDefinitionId, id),
);
};
export const createTestMetric = async (
context: IRestApiContext,
params: CreateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'POST',
getMetricsEndpoint(params.testDefinitionId),
{ name: params.name },
);
};
export const updateTestMetric = async (
context: IRestApiContext,
params: UpdateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'PATCH',
getMetricsEndpoint(params.testDefinitionId, params.id),
{ name: params.name },
);
};
export const deleteTestMetric = async (
context: IRestApiContext,
params: DeleteTestMetricParams,
) => {
return await makeRestApiRequest(
context,
'DELETE',
getMetricsEndpoint(params.testDefinitionId, params.id),
);
};
const getRunsEndpoint = (testDefinitionId: string, runId?: string) =>
`${endpoint}/${testDefinitionId}/runs${runId ? `/${runId}` : ''}`;
// Get all test runs for a test definition
export const getTestRuns = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestRunRecord[]>(
context,
'GET',
getRunsEndpoint(testDefinitionId),
);
};
// Get specific test run
export const getTestRun = async (context: IRestApiContext, params: GetTestRunParams) => {
return await makeRestApiRequest<TestRunRecord>(
context,
'GET',
getRunsEndpoint(params.testDefinitionId, params.runId),
);
};
// Start a new test run
export const startTestRun = async (context: IRestApiContext, testDefinitionId: string) => {
const response = await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${endpoint}/${testDefinitionId}/run`,
headers: { 'push-ref': context.pushRef },
});
// CLI is returning the response without wrapping it in `data` key
return response as { success: boolean };
};
// Delete a test run
export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => {
return await makeRestApiRequest<{ success: boolean }>(
context,
'DELETE',
getRunsEndpoint(params.testDefinitionId, params.runId),
);
};

View file

@ -1,32 +1,24 @@
<script setup lang="ts">
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import type { LanguageSupport } from '@codemirror/language';
import type { Extension, Line } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import { EditorView } from '@codemirror/view';
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { CODE_NODE_TYPE } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus';
import { useRootStore } from '@/stores/root.store';
import { useCodeEditor } from '@/composables/useCodeEditor';
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import { useTelemetry } from '@/composables/useTelemetry';
import AskAI from './AskAI/AskAI.vue';
import { readOnlyEditorExtensions, writableEditorExtensions } from './baseExtensions';
import { useCompleter } from './completer';
import { CODE_PLACEHOLDERS } from './constants';
import { useLinter } from './linter';
import { codeNodeEditorTheme } from './theme';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { dropInCodeEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { useSettingsStore } from '@/stores/settings.store';
import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
mode: CodeExecutionMode;
@ -36,6 +28,7 @@ type Props = {
language?: CodeNodeEditorLanguage;
isReadOnly?: boolean;
rows?: number;
id?: string;
};
const props = withDefaults(defineProps<Props>(), {
@ -44,99 +37,57 @@ const props = withDefaults(defineProps<Props>(), {
language: 'javaScript',
isReadOnly: false,
rows: 4,
id: crypto.randomUUID(),
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const message = useMessage();
const editor = ref(null) as Ref<EditorView | null>;
const languageCompartment = ref(new Compartment());
const dragAndDropCompartment = ref(new Compartment());
const linterCompartment = ref(new Compartment());
const isEditorHovered = ref(false);
const isEditorFocused = ref(false);
const tabs = ref(['code', 'ask-ai']);
const activeTab = ref('code');
const hasChanges = ref(false);
const isLoadingAIResponse = ref(false);
const codeNodeEditorRef = ref<HTMLDivElement>();
const codeNodeEditorContainerRef = ref<HTMLDivElement>();
const { autocompletionExtension } = useCompleter(() => props.mode, editor);
const { createLinter } = useLinter(() => props.mode, editor);
const hasManualChanges = ref(false);
const rootStore = useRootStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const settingsStore = useSettingsStore();
const linter = useLinter(
() => props.mode,
() => props.language,
);
const extensions = computed(() => [linter.value]);
const placeholder = computed(() => CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '');
const dragAndDropEnabled = computed(() => {
return !props.isReadOnly;
});
const { highlightLine, readEditorValue, editor } = useCodeEditor({
id: props.id,
editorRef: codeNodeEditorRef,
language: () => props.language,
languageParams: () => ({ mode: props.mode }),
editorValue: () => props.modelValue,
placeholder,
extensions,
isReadOnly: () => props.isReadOnly,
theme: {
maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: props.rows,
},
onChange: onEditorUpdate,
});
onMounted(() => {
if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine);
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied);
const { isReadOnly, language } = props;
const extensions: Extension[] = [
...readOnlyEditorExtensions,
EditorState.readOnly.of(isReadOnly),
EditorView.editable.of(!isReadOnly),
codeNodeEditorTheme({
isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh',
rows: props.rows,
}),
];
if (!isReadOnly) {
const linter = createLinter(language);
if (linter) {
extensions.push(linterCompartment.value.of(linter));
}
extensions.push(
...writableEditorExtensions,
dragAndDropCompartment.value.of(dragAndDropExtension.value),
EditorView.domEventHandlers({
focus: () => {
isEditorFocused.value = true;
},
blur: () => {
isEditorFocused.value = false;
},
}),
EditorView.updateListener.of((viewUpdate) => {
if (!viewUpdate.docChanged) return;
trackCompletion(viewUpdate);
const value = editor.value?.state.doc.toString();
if (value) {
emit('update:modelValue', value);
}
hasChanges.value = true;
}),
);
}
const [languageSupport, ...otherExtensions] = languageExtensions.value;
extensions.push(languageCompartment.value.of(languageSupport), ...otherExtensions);
const state = EditorState.create({
doc: props.modelValue ?? placeholder.value,
extensions,
});
editor.value = new EditorView({
parent: codeNodeEditorRef.value,
state,
});
// empty on first load, default param value
if (!props.modelValue) {
refreshPlaceholder();
emit('update:modelValue', placeholder.value);
}
});
@ -150,89 +101,12 @@ const askAiEnabled = computed(() => {
return settingsStore.isAskAiEnabled && props.language === 'javaScript';
});
const placeholder = computed(() => {
return CODE_PLACEHOLDERS[props.language]?.[props.mode] ?? '';
});
const dragAndDropEnabled = computed(() => {
return !props.isReadOnly && props.mode === 'runOnceForEachItem';
});
const dragAndDropExtension = computed(() => (dragAndDropEnabled.value ? mappingDropCursor() : []));
// eslint-disable-next-line vue/return-in-computed-property
const languageExtensions = computed<[LanguageSupport, ...Extension[]]>(() => {
switch (props.language) {
case 'javaScript':
return [javascript(), autocompletionExtension('javaScript')];
case 'python':
return [python(), autocompletionExtension('python')];
watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => {
if (readEditorValue().trim() === CODE_PLACEHOLDERS[prevLanguage]?.[prevMode]) {
emit('update:modelValue', placeholder.value);
}
});
watch(
() => props.modelValue,
(newValue) => {
if (!editor.value) {
return;
}
const current = editor.value.state.doc.toString();
if (current === newValue) {
return;
}
editor.value.dispatch({
changes: { from: 0, to: getCurrentEditorContent().length, insert: newValue },
});
},
);
watch(
() => props.mode,
(_newMode, previousMode: CodeExecutionMode) => {
reloadLinter();
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[props.language]?.[previousMode]) {
refreshPlaceholder();
}
},
);
watch(dragAndDropExtension, (extension) => {
editor.value?.dispatch({
effects: dragAndDropCompartment.value.reconfigure(extension),
});
});
watch(
() => props.language,
(_newLanguage, previousLanguage: CodeNodeEditorLanguage) => {
if (getCurrentEditorContent().trim() === CODE_PLACEHOLDERS[previousLanguage]?.[props.mode]) {
refreshPlaceholder();
}
const [languageSupport] = languageExtensions.value;
editor.value?.dispatch({
effects: languageCompartment.value.reconfigure(languageSupport),
});
reloadLinter();
},
);
watch(
askAiEnabled,
async (isEnabled) => {
if (isEnabled && !props.modelValue) {
emit('update:modelValue', placeholder.value);
}
await nextTick();
hasChanges.value = props.modelValue !== placeholder.value;
},
{ immediate: true },
);
function getCurrentEditorContent() {
return editor.value?.state.doc.toString() ?? '';
}
async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
// Confirm dialog if leaving ask-ai tab during loading
if (oldActiveName === 'ask-ai' && isLoadingAIResponse.value) {
@ -243,69 +117,28 @@ async function onBeforeTabLeave(_activeName: string, oldActiveName: string) {
showCancelButton: true,
});
if (confirmModal === 'confirm') {
return true;
}
return false;
return confirmModal === 'confirm';
}
return true;
}
async function onReplaceCode(code: string) {
async function onAiReplaceCode(code: string) {
const formattedCode = await format(code, {
parser: 'babel',
plugins: [jsParser, estree],
});
editor.value?.dispatch({
changes: { from: 0, to: getCurrentEditorContent().length, insert: formattedCode },
});
emit('update:modelValue', formattedCode);
activeTab.value = 'code';
hasChanges.value = false;
hasManualChanges.value = false;
}
function onMouseOver(event: MouseEvent) {
const fromElement = event.relatedTarget as HTMLElement;
const containerRef = codeNodeEditorContainerRef.value;
if (!containerRef?.contains(fromElement)) isEditorHovered.value = true;
}
function onMouseOut(event: MouseEvent) {
const fromElement = event.relatedTarget as HTMLElement;
const containerRef = codeNodeEditorContainerRef.value;
if (!containerRef?.contains(fromElement)) isEditorHovered.value = false;
}
function reloadLinter() {
if (!editor.value) return;
const linter = createLinter(props.language);
if (linter) {
editor.value.dispatch({
effects: linterCompartment.value.reconfigure(linter),
});
}
}
function refreshPlaceholder() {
if (!editor.value) return;
editor.value.dispatch({
changes: { from: 0, to: getCurrentEditorContent().length, insert: placeholder.value },
});
}
function getLine(lineNumber: number): Line | null {
try {
return editor.value?.state.doc.line(lineNumber) ?? null;
} catch {
return null;
}
function onEditorUpdate(viewUpdate: ViewUpdate) {
trackCompletion(viewUpdate);
hasManualChanges.value = true;
emit('update:modelValue', readEditorValue());
}
function diffApplied() {
@ -315,25 +148,6 @@ function diffApplied() {
});
}
function highlightLine(lineNumber: number | 'final') {
if (!editor.value) return;
if (lineNumber === 'final') {
editor.value.dispatch({
selection: { anchor: (props.modelValue ?? getCurrentEditorContent()).length },
});
return;
}
const line = getLine(lineNumber);
if (!line) return;
editor.value.dispatch({
selection: { anchor: line.from },
});
}
function trackCompletion(viewUpdate: ViewUpdate) {
const completionTx = viewUpdate.transactions.find((tx) => tx.isUserEvent('input.complete'));
@ -342,7 +156,7 @@ function trackCompletion(viewUpdate: ViewUpdate) {
try {
// @ts-expect-error - undocumented fields
const { fromA, toB } = viewUpdate?.changedRanges[0];
const full = getCurrentEditorContent().slice(fromA, toB);
const full = viewUpdate.state.doc.slice(fromA, toB).toString();
const lastDotIndex = full.lastIndexOf('.');
let context = null;
@ -379,16 +193,19 @@ function onAiLoadEnd() {
async function onDrop(value: string, event: MouseEvent) {
if (!editor.value) return;
await dropInCodeEditor(toRaw(editor.value), event, value);
const valueToInsert =
props.mode === 'runOnceForAllItems'
? value.replace('$json', '$input.first().json').replace(/\$\((.*)\)\.item/, '$($1).first()')
: value;
await dropInCodeEditor(toRaw(editor.value), event, valueToInsert);
}
</script>
<template>
<div
ref="codeNodeEditorContainerRef"
:class="['code-node-editor', $style['code-node-editor-container'], language]"
@mouseover="onMouseOver"
@mouseout="onMouseOut"
:class="['code-node-editor', $style['code-node-editor-container']]"
>
<el-tabs
v-if="askAiEnabled"
@ -433,8 +250,8 @@ async function onDrop(value: string, event: MouseEvent) {
<!-- Key the AskAI tab to make sure it re-mounts when changing tabs -->
<AskAI
:key="activeTab"
:has-changes="hasChanges"
@replace-code="onReplaceCode"
:has-changes="hasManualChanges"
@replace-code="onAiReplaceCode"
@started-loading="onAiLoadStart"
@finished-loading="onAiLoadEnd"
/>

View file

@ -1,49 +0,0 @@
import {
dropCursor,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
} from '@codemirror/view';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { history, toggleComment, deleteCharBackward } from '@codemirror/commands';
import { lintGutter } from '@codemirror/lint';
import { type Extension, Prec } from '@codemirror/state';
import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
export const readOnlyEditorExtensions: readonly Extension[] = [
lineNumbers(),
EditorView.lineWrapping,
highlightSpecialChars(),
];
export const writableEditorExtensions: readonly Extension[] = [
history(),
lintGutter(),
foldGutter(),
codeInputHandler(),
dropCursor(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
highlightActiveLineGutter(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...autocompleteKeyMap,
...historyKeyMap,
{ key: 'Mod-/', run: toggleComment },
{ key: 'Backspace', run: deleteCharBackward, shift: deleteCharBackward },
]),
),
];

View file

@ -28,7 +28,7 @@ export const AUTOCOMPLETABLE_BUILT_IN_MODULES_JS = [
export const DEFAULT_LINTER_SEVERITY: Diagnostic['severity'] = 'error';
export const DEFAULT_LINTER_DELAY_IN_MS = 300;
export const DEFAULT_LINTER_DELAY_IN_MS = 500;
/**
* Length of the start of the script wrapper, used as offset for the linter to find a location in source text.

View file

@ -1,10 +1,10 @@
import type { Diagnostic } from '@codemirror/lint';
import { linter } from '@codemirror/lint';
import { linter as codeMirrorLinter } from '@codemirror/lint';
import type { EditorView } from '@codemirror/view';
import * as esprima from 'esprima-next';
import type { Node, MemberExpression } from 'estree';
import type { CodeExecutionMode, CodeNodeEditorLanguage } from 'n8n-workflow';
import { toValue, type MaybeRefOrGetter } from 'vue';
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import { useI18n } from '@/composables/useI18n';
import {
@ -17,17 +17,17 @@ import { walk } from './utils';
export const useLinter = (
mode: MaybeRefOrGetter<CodeExecutionMode>,
editor: MaybeRefOrGetter<EditorView | null>,
language: MaybeRefOrGetter<CodeNodeEditorLanguage>,
) => {
const i18n = useI18n();
function createLinter(language: CodeNodeEditorLanguage) {
switch (language) {
const linter = computed(() => {
switch (toValue(language)) {
case 'javaScript':
return linter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
return codeMirrorLinter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
}
return undefined;
}
return [];
});
function lintSource(editorView: EditorView): Diagnostic[] {
const doc = editorView.state.doc.toString();
@ -38,34 +38,7 @@ export const useLinter = (
try {
ast = esprima.parseScript(script, { range: true });
} catch (syntaxError) {
let line;
try {
const lineAtError = editorView.state.doc.line(syntaxError.lineNumber - 1).text;
// optional chaining operators currently unsupported by esprima-next
if (['?.', ']?'].some((operator) => lineAtError.includes(operator))) return [];
} catch {
return [];
}
try {
line = editorView.state.doc.line(syntaxError.lineNumber);
return [
{
from: line.from,
to: line.to,
severity: DEFAULT_LINTER_SEVERITY,
message: i18n.baseText('codeNodeEditor.linter.bothModes.syntaxError'),
},
];
} catch {
/**
* For invalid (e.g. half-written) n8n syntax, esprima errors with an off-by-one line number for the final line. In future, we should add full linting for n8n syntax before parsing JS.
*/
return [];
}
return [];
}
if (ast === null) return [];
@ -118,7 +91,7 @@ export const useLinter = (
walk(ast, isUnavailableVarInAllItems).forEach((node) => {
const [start, end] = getRange(node);
const varName = getText(node);
const varName = getText(editorView, node);
if (!varName) return;
@ -250,7 +223,7 @@ export const useLinter = (
walk<TargetNode>(ast, isUnavailableMethodinEachItem).forEach((node) => {
const [start, end] = getRange(node.property);
const method = getText(node.property);
const method = getText(editorView, node.property);
if (!method) return;
@ -444,7 +417,7 @@ export const useLinter = (
if (shadowStart && start > shadowStart) return; // skip shadow item
const varName = getText(node);
const varName = getText(editorView, node);
if (!varName) return;
@ -489,7 +462,7 @@ export const useLinter = (
!['json', 'binary'].includes(node.property.name);
walk<TargetNode>(ast, isDirectAccessToItemSubproperty).forEach((node) => {
const varName = getText(node);
const varName = getText(editorView, node);
if (!varName) return;
@ -636,19 +609,15 @@ export const useLinter = (
// helpers
// ----------------------------------
function getText(node: RangeNode) {
const editorValue = toValue(editor);
if (!editorValue) return null;
function getText(editorView: EditorView, node: RangeNode) {
const [start, end] = getRange(node);
return editorValue.state.doc.toString().slice(start, end);
return editorView.state.doc.toString().slice(start, end);
}
function getRange(node: RangeNode) {
return node.range.map((loc) => loc - OFFSET_FOR_SCRIPT_WRAPPER);
}
return { createLinter };
return linter;
};

View file

@ -32,16 +32,62 @@ interface ThemeSettings {
maxHeight?: string;
minHeight?: string;
rows?: number;
highlightColors?: 'default' | 'html';
}
export const codeNodeEditorTheme = ({
isReadOnly,
minHeight,
maxHeight,
rows,
highlightColors,
}: ThemeSettings) => [
const codeEditorSyntaxHighlighting = syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.keyword, color: 'var(--color-code-tags-keyword)' },
{
tag: [
tags.deleted,
tags.character,
tags.macroName,
tags.definition(tags.name),
tags.definition(tags.variableName),
tags.atom,
tags.bool,
],
color: 'var(--color-code-tags-variable)',
},
{ tag: [tags.name, tags.propertyName], color: 'var(--color-code-tags-property)' },
{
tag: [tags.processingInstruction, tags.string, tags.inserted, tags.special(tags.string)],
color: 'var(--color-code-tags-string)',
},
{
tag: [tags.function(tags.variableName), tags.labelName],
color: 'var(--color-code-tags-function)',
},
{
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
color: 'var(--color-code-tags-constant)',
},
{ tag: [tags.className], color: 'var(--color-code-tags-class)' },
{
tag: [tags.number, tags.changed, tags.annotation, tags.modifier, tags.self, tags.namespace],
color: 'var(--color-code-tags-primitive)',
},
{ tag: [tags.typeName], color: 'var(--color-code-tags-type)' },
{ tag: [tags.operator, tags.operatorKeyword], color: 'var(--color-code-tags-keyword)' },
{
tag: [tags.url, tags.escape, tags.regexp, tags.link],
color: 'var(--color-code-tags-keyword)',
},
{ tag: [tags.meta, tags.comment, tags.lineComment], color: 'var(--color-code-tags-comment)' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.link, textDecoration: 'underline' },
{ tag: tags.heading, fontWeight: 'bold', color: 'var(--color-code-tags-heading)' },
{ tag: tags.invalid, color: 'var(--color-code-tags-invalid)' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{
tag: [tags.derefOperator, tags.special(tags.variableName), tags.variableName, tags.separator],
color: 'var(--color-code-foreground)',
},
]),
);
export const codeEditorTheme = ({ isReadOnly, minHeight, maxHeight, rows }: ThemeSettings) => [
EditorView.theme({
'&': {
'font-size': BASE_STYLING.fontSize,
@ -54,13 +100,17 @@ export const codeNodeEditorTheme = ({
'.cm-content': {
fontFamily: BASE_STYLING.fontFamily,
caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)',
lineHeight: 'var(--font-line-height-xloose)',
paddingTop: 'var(--spacing-2xs)',
paddingBottom: 'var(--spacing-s)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--color-code-caret)',
},
'&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'var(--color-code-selection)',
},
'&.cm-focused > .cm-scroller .cm-selectionLayer > .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{
background: 'var(--color-code-selection)',
},
'&.cm-editor': {
...(isReadOnly ? { backgroundColor: 'var(--color-code-background-readonly)' } : {}),
borderColor: 'var(--border-color-base)',
@ -75,13 +125,19 @@ export const codeNodeEditorTheme = ({
'.cm-activeLineGutter': {
backgroundColor: 'var(--color-code-lineHighlight)',
},
'.cm-lineNumbers .cm-activeLineGutter': {
color: 'var(--color-code-gutter-foreground-active)',
},
'.cm-gutters': {
backgroundColor: isReadOnly
? 'var(--color-code-background-readonly)'
: 'var(--color-code-gutterBackground)',
color: 'var(--color-code-gutterForeground)',
: 'var(--color-code-gutter-background)',
color: 'var(--color-code-gutter-foreground)',
border: '0',
borderRadius: 'var(--border-radius-base)',
borderRightColor: 'var(--border-color-base)',
},
'.cm-gutterElement': {
padding: 0,
},
'.cm-tooltip': {
maxWidth: BASE_STYLING.tooltip.maxWidth,
@ -92,11 +148,30 @@ export const codeNodeEditorTheme = ({
maxHeight: maxHeight ?? '100%',
...(isReadOnly
? {}
: { minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto' }),
: {
minHeight: rows && rows !== -1 ? `${Number(rows + 1) * 1.3}em` : 'auto',
}),
},
'.cm-lineNumbers .cm-gutterElement': {
padding: '0 var(--spacing-5xs) 0 var(--spacing-2xs)',
},
'.cm-gutter,.cm-content': {
minHeight: rows && rows !== -1 ? 'auto' : (minHeight ?? 'calc(35vh - var(--spacing-2xl))'),
},
'.cm-foldGutter': {
width: '16px',
},
'.cm-fold-marker': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
opacity: 0,
transition: 'opacity 0.3s ease',
},
'.cm-activeLineGutter .cm-fold-marker, .cm-gutters:hover .cm-fold-marker': {
opacity: 1,
},
'.cm-diagnosticAction': {
backgroundColor: BASE_STYLING.diagnosticButton.backgroundColor,
color: 'var(--color-primary)',
@ -106,103 +181,81 @@ export const codeNodeEditorTheme = ({
cursor: BASE_STYLING.diagnosticButton.cursor,
},
'.cm-diagnostic-error': {
backgroundColor: 'var(--color-background-base)',
backgroundColor: 'var(--color-infobox-background)',
},
'.cm-diagnosticText': {
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-base)',
},
'.cm-diagnosticDocs': {
fontSize: 'var(--font-size-2xs)',
},
'.cm-foldPlaceholder': {
color: 'var(--color-text-base)',
backgroundColor: 'var(--color-background-base)',
border: 'var(--border-base)',
},
'.cm-selectionMatch': {
background: 'var(--color-code-selection-highlight)',
},
'.cm-selectionMatch-main': {
background: 'var(--color-code-selection-highlight)',
},
'.cm-matchingBracket': {
background: 'var(--color-code-selection)',
},
'.cm-completionMatchedText': {
textDecoration: 'none',
fontWeight: '600',
color: 'var(--color-autocomplete-item-selected)',
},
'.cm-faded > span': {
opacity: 0.6,
},
'.cm-panel.cm-search': {
padding: 'var(--spacing-4xs) var(--spacing-2xs)',
},
'.cm-panels': {
background: 'var(--color-background-light)',
color: 'var(--color-text-base)',
},
'.cm-panels-bottom': {
borderTop: 'var(--border-base)',
},
'.cm-textfield': {
color: 'var(--color-text-dark)',
background: 'var(--color-foreground-xlight)',
borderRadius: 'var(--border-radius-base)',
border: 'var(--border-base)',
fontSize: '90%',
},
'.cm-textfield:focus': {
outline: 'none',
borderColor: 'var(--color-secondary)',
},
'.cm-panel button': {
color: 'var(--color-text-base)',
},
'.cm-panel input[type="checkbox"]': {
border: 'var(--border-base)',
outline: 'none',
},
'.cm-panel input[type="checkbox"]:hover': {
border: 'var(--border-base)',
outline: 'none',
},
'.cm-panel.cm-search label': {
fontSize: '90%',
},
'.cm-button': {
outline: 'none',
border: 'var(--border-base)',
color: 'var(--color-text-dark)',
backgroundColor: 'var(--color-foreground-xlight)',
backgroundImage: 'none',
borderRadius: 'var(--border-radius-base)',
fontSize: '90%',
},
}),
highlightColors === 'html'
? syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.keyword, color: '#c678dd' },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
color: '#e06c75',
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
{
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
color: '#d19a66',
},
{ tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
{
tag: [
tags.typeName,
tags.className,
tags.number,
tags.changed,
tags.annotation,
tags.modifier,
tags.self,
tags.namespace,
],
color: '#e06c75',
},
{
tag: [
tags.operator,
tags.operatorKeyword,
tags.url,
tags.escape,
tags.regexp,
tags.link,
tags.special(tags.string),
],
color: '#56b6c2',
},
{ tag: [tags.meta, tags.comment], color: '#7d8799' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{ tag: tags.link, color: '#7d8799', textDecoration: 'underline' },
{ tag: tags.heading, fontWeight: 'bold', color: '#e06c75' },
{ tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
{ tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
{ tag: tags.invalid, color: 'red', 'font-weight': 'bold' },
]),
)
: syntaxHighlighting(
HighlightStyle.define([
{
tag: tags.comment,
color: 'var(--color-code-tags-comment)',
},
{
tag: [tags.string, tags.special(tags.brace)],
color: 'var(--color-code-tags-string)',
},
{
tag: [tags.number, tags.self, tags.bool, tags.null],
color: 'var(--color-code-tags-primitive)',
},
{
tag: tags.keyword,
color: 'var(--color-code-tags-keyword)',
},
{
tag: tags.operator,
color: 'var(--color-code-tags-operator)',
},
{
tag: [
tags.variableName,
tags.propertyName,
tags.attributeName,
tags.regexp,
tags.className,
tags.typeName,
],
color: 'var(--color-code-tags-variable)',
},
{
tag: [
tags.definition(tags.typeName),
tags.definition(tags.propertyName),
tags.function(tags.variableName),
],
color: 'var(--color-code-tags-definition)',
},
]),
),
codeEditorSyntaxHighlighting,
];

View file

@ -245,7 +245,6 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
margin-bottom: 0;
:global(.el-dialog__body) {
background-color: var(--color-expression-editor-modal-background);
height: 100%;
padding: var(--spacing-s);
}

View file

@ -7,20 +7,14 @@ import { computed, onMounted, ref, watch } from 'vue';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { forceParse } from '@/utils/forceParse';
import { completionStatus } from '@codemirror/autocomplete';
import { inputTheme } from './theme';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Segment } from '@/types/expressions';
import { removeExpressionPrefix } from '@/utils/expressions';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { editorKeymap } from '@/plugins/codemirror/keymap';
type Props = {
modelValue: string;
@ -41,24 +35,7 @@ const emit = defineEmits<{
const root = ref<HTMLElement>();
const extensions = computed(() => [
inputTheme(props.isReadOnly),
Prec.highest(
keymap.of([
...tabKeyMap(),
...historyKeyMap,
...enterKeyMap,
...autocompleteKeyMap,
{
any: (view, event) => {
if (event.key === 'Escape' && completionStatus(view.state) === null) {
event.stopPropagation();
emit('close');
}
return false;
},
},
]),
),
Prec.highest(keymap.of(editorKeymap)),
n8nLang(),
n8nAutocompletion(),
mappingDropCursor(),
@ -66,7 +43,7 @@ const extensions = computed(() => [
history(),
expressionInputHandler(),
EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: forceParse }),
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
infoBoxTooltips(),
]);
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));

View file

@ -11,11 +11,16 @@ import { useRootStore } from '@/stores/root.store';
import { useToast } from '@/composables/useToast';
import { renderComponent } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { useTelemetry } from '@/composables/useTelemetry';
vi.mock('@/composables/useToast', () => ({
useToast: vi.fn(),
}));
vi.mock('@/composables/useTelemetry', () => ({
useTelemetry: vi.fn(),
}));
vi.mock('@/stores/settings.store', () => ({
useSettingsStore: vi.fn(),
}));
@ -55,7 +60,17 @@ const assertUserCanClaimCredits = () => {
};
const assertUserClaimedCredits = () => {
expect(screen.getByText('Claimed 100 free OpenAI API credits')).toBeInTheDocument();
expect(
screen.getByText(
'Claimed 100 free OpenAI API credits! Please note these free credits are only for the following models:',
),
).toBeInTheDocument();
expect(
screen.getByText(
'gpt-4o-mini, text-embedding-3-small, dall-e-3, tts-1, whisper-1, and text-moderation-latest',
),
).toBeInTheDocument();
};
describe('FreeAiCreditsCallout', () => {
@ -86,7 +101,7 @@ describe('FreeAiCreditsCallout', () => {
});
(usePostHog as any).mockReturnValue({
isFeatureEnabled: vi.fn().mockReturnValue(true),
getVariant: vi.fn().mockReturnValue('variant'),
});
(useProjectsStore as any).mockReturnValue({
@ -100,6 +115,10 @@ describe('FreeAiCreditsCallout', () => {
(useToast as any).mockReturnValue({
showError: vi.fn(),
});
(useTelemetry as any).mockReturnValue({
track: vi.fn(),
});
});
it('should shows the claim callout when the user can claim credits', () => {
@ -120,6 +139,7 @@ describe('FreeAiCreditsCallout', () => {
await fireEvent.click(claimButton);
expect(credentialsStore.claimFreeAiCredits).toHaveBeenCalledWith('test-project-id');
expect(useTelemetry().track).toHaveBeenCalledWith('User claimed OpenAI credits');
assertUserClaimedCredits();
});
@ -150,7 +170,7 @@ describe('FreeAiCreditsCallout', () => {
it('should not be able to claim credits if user it is not in experiment', async () => {
(usePostHog as any).mockReturnValue({
isFeatureEnabled: vi.fn().mockReturnValue(false),
getVariant: vi.fn().mockReturnValue('control'),
});
renderComponent(FreeAiCreditsCallout);

View file

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { AI_CREDITS_EXPERIMENT } from '@/constants';
import { useCredentialsStore } from '@/stores/credentials.store';
@ -9,8 +10,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { computed, ref } from 'vue';
const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
const LANGCHAIN_NODES_PREFIX = '@n8n/n8n-nodes-langchain.';
@ -27,11 +27,12 @@ const showSuccessCallout = ref(false);
const claimingCredits = ref(false);
const settingsStore = useSettingsStore();
const postHogStore = usePostHog();
const posthogStore = usePostHog();
const credentialsStore = useCredentialsStore();
const usersStore = useUsersStore();
const ndvStore = useNDVStore();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const i18n = useI18n();
const toast = useToast();
@ -57,7 +58,7 @@ const userCanClaimOpenAiCredits = computed(() => {
return (
settingsStore.isAiCreditsEnabled &&
activeNodeHasOpenAiApiCredential.value &&
postHogStore.isFeatureEnabled(AI_CREDITS_EXPERIMENT.name) &&
posthogStore.getVariant(AI_CREDITS_EXPERIMENT.name) === AI_CREDITS_EXPERIMENT.variant &&
!userHasOpenAiCredentialAlready.value &&
!userHasClaimedAiCreditsAlready.value
);
@ -73,6 +74,8 @@ const onClaimCreditsClicked = async () => {
usersStore.currentUser.settings.userClaimedAiCredits = true;
}
telemetry.track('User claimed OpenAI credits');
showSuccessCallout.value = true;
} catch (e) {
toast.showError(
@ -108,11 +111,16 @@ const onClaimCreditsClicked = async () => {
</template>
</n8n-callout>
<n8n-callout v-else-if="showSuccessCallout" theme="success" icon="check-circle">
{{
i18n.baseText('freeAi.credits.callout.success.title', {
interpolate: { credits: settingsStore.aiCreditsQuota },
})
}}
<n8n-text>
{{
i18n.baseText('freeAi.credits.callout.success.title.part1', {
interpolate: { credits: settingsStore.aiCreditsQuota },
})
}}</n8n-text
>&nbsp;
<n8n-text :bold="true">
{{ i18n.baseText('freeAi.credits.callout.success.title.part2') }}</n8n-text
>
</n8n-callout>
</div>
</template>

View file

@ -22,19 +22,14 @@ import htmlParser from 'prettier/plugins/html';
import cssParser from 'prettier/plugins/postcss';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, toValue, watch } from 'vue';
import { htmlEditorEventBus } from '@/event-bus';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { htmlEditorEventBus } from '@/event-bus';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
import type { Range, Section } from './types';
import { nonTakenRanges } from './utils';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
@ -67,16 +62,13 @@ const extensions = computed(() => [
),
autoCloseTags,
expressionInputHandler(),
Prec.highest(
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
),
Prec.highest(keymap.of(editorKeymap)),
indentOnInput(),
codeNodeEditorTheme({
codeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fullscreen ? '100%' : '40vh',
minHeight: '20vh',
rows: props.rows,
highlightColors: 'html',
}),
lineNumbers(),
highlightActiveLineGutter(),

View file

@ -97,7 +97,7 @@ onMounted(() => {
extensions: [
EditorState.readOnly.of(true),
EditorView.lineWrapping,
EditorView.domEventHandlers({ scroll: forceParse }),
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
...props.extensions,
],
}),

View file

@ -7,12 +7,7 @@ import { computed, ref, watch } from 'vue';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion, n8nLang } from '@/plugins/codemirror/n8nLang';
import { infoBoxTooltips } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Segment } from '@/types/expressions';
@ -42,9 +37,7 @@ const emit = defineEmits<{
const root = ref<HTMLElement>();
const extensions = computed(() => [
Prec.highest(
keymap.of([...tabKeyMap(false), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]),
),
Prec.highest(keymap.of(editorKeymap)),
n8nLang(),
n8nAutocompletion(),
inputTheme({ isReadOnly: props.isReadOnly, rows: props.rows }),

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { history, toggleComment } from '@codemirror/commands';
import { history } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { foldGutter, indentOnInput } from '@codemirror/language';
import { lintGutter } from '@codemirror/lint';
@ -16,14 +16,9 @@ import {
} from '@codemirror/view';
import { computed, onMounted, ref, watch } from 'vue';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
type Props = {
modelValue: string;
@ -85,7 +80,7 @@ const extensions = computed(() => {
lineNumbers(),
EditorView.lineWrapping,
EditorState.readOnly.of(props.isReadOnly),
codeNodeEditorTheme({
codeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh',
@ -96,15 +91,7 @@ const extensions = computed(() => {
if (!props.isReadOnly) {
extensionsToApply.push(
history(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
Prec.highest(keymap.of(editorKeymap)),
lintGutter(),
n8nAutocompletion(),
indentOnInput(),

View file

@ -15,15 +15,10 @@ import {
lineNumbers,
} from '@codemirror/view';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { computed, onMounted, ref, watch } from 'vue';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
type Props = {
@ -48,7 +43,7 @@ const extensions = computed(() => {
lineNumbers(),
EditorView.lineWrapping,
EditorState.readOnly.of(props.isReadOnly),
codeNodeEditorTheme({
codeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fillParent ? '100%' : '40vh',
minHeight: '20vh',
@ -58,9 +53,7 @@ const extensions = computed(() => {
if (!props.isReadOnly) {
extensionsToApply.push(
history(),
Prec.highest(
keymap.of([...tabKeyMap(), ...enterKeyMap, ...historyKeyMap, ...autocompleteKeyMap]),
),
Prec.highest(keymap.of(editorKeymap)),
createLinter(jsonParseLinter()),
lintGutter(),
n8nAutocompletion(),

View file

@ -43,6 +43,25 @@ const executionToReturnTo = ref('');
const dirtyState = ref(false);
const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON, false);
// Track the routes that are used for the tabs
// This is used to determine which tab to show when the route changes
// TODO: It might be easier to manage this in the router config, by passing meta information to the routes
// This would allow us to specify it just once on the root route, and then have the tabs be determined for children
const testDefinitionRoutes: VIEWS[] = [
VIEWS.TEST_DEFINITION,
VIEWS.TEST_DEFINITION_EDIT,
VIEWS.TEST_DEFINITION_RUNS,
VIEWS.TEST_DEFINITION_RUNS_DETAIL,
VIEWS.TEST_DEFINITION_RUNS_COMPARE,
];
const workflowRoutes: VIEWS[] = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
const executionRoutes: VIEWS[] = [
VIEWS.EXECUTION_HOME,
VIEWS.WORKFLOW_EXECUTIONS,
VIEWS.EXECUTION_PREVIEW,
];
const tabBarItems = computed(() => {
const items = [
{ value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') },
@ -92,24 +111,30 @@ onMounted(async () => {
syncTabsWithRoute(route);
});
function isViewRoute(name: unknown): name is VIEWS {
return (
typeof name === 'string' &&
[testDefinitionRoutes, workflowRoutes, executionRoutes].flat().includes(name as VIEWS)
);
}
function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void {
if (to.matched.some((record) => record.name === VIEWS.TEST_DEFINITION)) {
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION;
}
if (
to.name === VIEWS.EXECUTION_HOME ||
to.name === VIEWS.WORKFLOW_EXECUTIONS ||
to.name === VIEWS.EXECUTION_PREVIEW
) {
activeHeaderTab.value = MAIN_HEADER_TABS.EXECUTIONS;
} else if (
to.name === VIEWS.WORKFLOW ||
to.name === VIEWS.NEW_WORKFLOW ||
to.name === VIEWS.EXECUTION_DEBUG
) {
activeHeaderTab.value = MAIN_HEADER_TABS.WORKFLOW;
// Map route types to their corresponding tab in the header
const routeTabMapping = [
{ routes: testDefinitionRoutes, tab: MAIN_HEADER_TABS.TEST_DEFINITION },
{ routes: executionRoutes, tab: MAIN_HEADER_TABS.EXECUTIONS },
{ routes: workflowRoutes, tab: MAIN_HEADER_TABS.WORKFLOW },
];
// Update the active tab based on the current route
if (to.name && isViewRoute(to.name)) {
const matchingTab = routeTabMapping.find(({ routes }) => routes.includes(to.name as VIEWS));
if (matchingTab) {
activeHeaderTab.value = matchingTab.tab;
}
}
// Store the current workflow ID, but only if it's not a new workflow
if (to.params.name !== 'new' && typeof to.params.name === 'string') {
workflowToReturnTo.value = to.params.name;
}

View file

@ -801,7 +801,6 @@ $--header-spacing: 20px;
color: $custom-font-dark;
font-size: 15px;
display: block;
min-width: 150px;
}
.activator {
@ -848,6 +847,14 @@ $--header-spacing: 20px;
gap: var(--spacing-m);
flex-wrap: nowrap;
}
@include mixins.breakpoint('xs-only') {
.name {
:deep(input) {
min-width: 180px;
}
}
}
</style>
<style module lang="scss">

View file

@ -476,6 +476,10 @@ const shortPath = computed<string>(() => {
return short.join('.');
});
const parameterId = computed(() => {
return `${node.value?.id ?? crypto.randomUUID()}${props.path}`;
});
const isResourceLocatorParameter = computed<boolean>(() => {
return props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector';
});
@ -1092,21 +1096,20 @@ onUpdated(async () => {
"
>
<el-dialog
width="calc(100% - var(--spacing-3xl))"
:class="$style.modal"
:model-value="codeEditDialogVisible"
:append-to="`#${APP_MODALS_ELEMENT_ID}`"
width="80%"
:title="`${i18n.baseText('codeEdit.edit')} ${i18n
.nodeText()
.inputLabelDisplayName(parameter, path)}`"
:before-close="closeCodeEditDialog"
data-test-id="code-editor-fullscreen"
>
<div
:key="codeEditDialogVisible.toString()"
class="ignore-key-press-canvas code-edit-dialog"
>
<div class="ignore-key-press-canvas code-edit-dialog">
<CodeNodeEditor
v-if="editorType === 'codeNodeEditor'"
:id="parameterId"
:mode="codeEditorMode"
:model-value="modelValueString"
:default-value="parameter.default"
@ -1116,7 +1119,7 @@ onUpdated(async () => {
@update:model-value="valueChangedDebounced"
/>
<HtmlEditor
v-else-if="editorType === 'htmlEditor'"
v-else-if="editorType === 'htmlEditor' && !codeEditDialogVisible"
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
@ -1126,7 +1129,7 @@ onUpdated(async () => {
@update:model-value="valueChangedDebounced"
/>
<SqlEditor
v-else-if="editorType === 'sqlEditor'"
v-else-if="editorType === 'sqlEditor' && !codeEditDialogVisible"
:model-value="modelValueString"
:dialect="getArgument('sqlDialect')"
:is-read-only="isReadOnly"
@ -1135,7 +1138,7 @@ onUpdated(async () => {
@update:model-value="valueChangedDebounced"
/>
<JsEditor
v-else-if="editorType === 'jsEditor'"
v-else-if="editorType === 'jsEditor' && !codeEditDialogVisible"
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
@ -1145,7 +1148,7 @@ onUpdated(async () => {
/>
<JsonEditor
v-else-if="parameter.type === 'json'"
v-else-if="parameter.type === 'json' && !codeEditDialogVisible"
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
@ -1166,8 +1169,8 @@ onUpdated(async () => {
></TextEdit>
<CodeNodeEditor
v-if="editorType === 'codeNodeEditor' && isCodeNode"
:key="'code-' + codeEditDialogVisible.toString()"
v-if="editorType === 'codeNodeEditor' && isCodeNode && !codeEditDialogVisible"
:id="parameterId"
:mode="codeEditorMode"
:model-value="modelValueString"
:default-value="parameter.default"
@ -1191,8 +1194,7 @@ onUpdated(async () => {
</CodeNodeEditor>
<HtmlEditor
v-else-if="editorType === 'htmlEditor'"
:key="'html-' + codeEditDialogVisible.toString()"
v-else-if="editorType === 'htmlEditor' && !codeEditDialogVisible"
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
@ -1214,7 +1216,6 @@ onUpdated(async () => {
<SqlEditor
v-else-if="editorType === 'sqlEditor'"
:key="'sql-' + codeEditDialogVisible.toString()"
:model-value="modelValueString"
:dialect="getArgument('sqlDialect')"
:is-read-only="isReadOnly"
@ -1235,7 +1236,6 @@ onUpdated(async () => {
<JsEditor
v-else-if="editorType === 'jsEditor'"
:key="'js-' + codeEditDialogVisible.toString()"
:model-value="modelValueString"
:is-read-only="isReadOnly || editorIsReadOnly"
:rows="editorRows"
@ -1257,7 +1257,6 @@ onUpdated(async () => {
<JsonEditor
v-else-if="parameter.type === 'json'"
:key="'json-' + codeEditDialogVisible.toString()"
:model-value="modelValueString"
:is-read-only="isReadOnly"
:rows="editorRows"
@ -1278,6 +1277,7 @@ onUpdated(async () => {
<div v-else-if="editorType" class="readonly-code clickable" @click="displayEditDialog()">
<CodeNodeEditor
v-if="!codeEditDialogVisible"
:id="parameterId"
:mode="codeEditorMode"
:model-value="modelValueString"
:language="editorLanguage"
@ -1630,8 +1630,8 @@ onUpdated(async () => {
.textarea-modal-opener {
position: absolute;
right: 0;
bottom: 0;
right: 1px;
bottom: 1px;
background-color: var(--color-code-background);
padding: 3px;
line-height: 9px;
@ -1639,6 +1639,8 @@ onUpdated(async () => {
border-top-left-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base);
cursor: pointer;
border-right: none;
border-bottom: none;
svg {
width: 9px !important;
@ -1660,7 +1662,7 @@ onUpdated(async () => {
}
.code-edit-dialog {
height: 70vh;
height: 100%;
.code-node-editor {
height: 100%;
@ -1668,7 +1670,25 @@ onUpdated(async () => {
}
</style>
<style lang="scss" module>
<style lang="css" module>
.modal {
--dialog-close-top: var(--spacing-m);
display: flex;
flex-direction: column;
overflow: clip;
height: calc(100% - var(--spacing-4xl));
margin-bottom: 0;
:global(.el-dialog__header) {
padding-bottom: 0;
}
:global(.el-dialog__body) {
height: calc(100% - var(--spacing-3xl));
padding: var(--spacing-s);
}
}
.tipVisible {
--input-border-bottom-left-radius: 0;
--input-border-bottom-right-radius: 0;

View file

@ -63,7 +63,6 @@ const state = reactive({
value: {},
matchingColumns: [] as string[],
schema: [] as ResourceMapperField[],
ignoreTypeMismatchErrors: false,
attemptToConvertTypes: false,
// This should always be true if `showTypeConversionOptions` is provided
// It's used to avoid accepting any value as string without casting it
@ -664,23 +663,6 @@ defineExpose({
}
"
/>
<ParameterInputFull
:parameter="{
name: 'ignoreTypeMismatchErrors',
type: 'boolean',
displayName: locale.baseText('resourceMapper.ignoreTypeMismatchErrors.displayName'),
default: false,
description: locale.baseText('resourceMapper.ignoreTypeMismatchErrors.description'),
}"
:path="props.path + '.ignoreTypeMismatchErrors'"
:value="state.paramValue.ignoreTypeMismatchErrors"
@update="
(x: IUpdateInformation<NodeParameterValueType>) => {
state.paramValue.ignoreTypeMismatchErrors = x.value as boolean;
emitValueChanged();
}
"
/>
</div>
</div>
</template>

View file

@ -282,7 +282,7 @@ describe('RunData', () => {
});
expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
expect(getByTestId('related-execution-link')).toHaveTextContent('View sub-execution');
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
@ -311,7 +311,7 @@ describe('RunData', () => {
});
expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Parent Execution 123');
expect(getByTestId('related-execution-link')).toHaveTextContent('View parent execution');
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
@ -344,7 +344,7 @@ describe('RunData', () => {
});
expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
expect(getByTestId('related-execution-link')).toHaveTextContent('View sub-execution 123');
expect(resolveRelatedExecutionUrl).toHaveBeenCalledWith(metadata);
expect(getByTestId('related-execution-link')).toHaveAttribute('href', MOCK_EXECUTION_URL);
@ -389,7 +389,7 @@ describe('RunData', () => {
});
expect(getByTestId('related-execution-link')).toBeInTheDocument();
expect(getByTestId('related-execution-link')).toHaveTextContent('Inspect Sub-Execution 123');
expect(getByTestId('related-execution-link')).toHaveTextContent('View sub-execution 123');
expect(queryByTestId('ndv-items-count')).not.toBeInTheDocument();
expect(getByTestId('run-selector')).toBeInTheDocument();

View file

@ -1263,9 +1263,13 @@ function getExecutionLinkLabel(task: ITaskMetadata): string | undefined {
}
if (task.subExecution) {
return i18n.baseText('runData.openSubExecution', {
interpolate: { id: task.subExecution.executionId },
});
if (activeTaskMetadata.value?.subExecutionsCount === 1) {
return i18n.baseText('runData.openSubExecutionSingle');
} else {
return i18n.baseText('runData.openSubExecutionWithId', {
interpolate: { id: task.subExecution.executionId },
});
}
}
return;

View file

@ -149,7 +149,7 @@ const outputError = computed(() => {
>
<N8nIcon icon="external-link-alt" size="xsmall" />
{{
i18n.baseText('runData.openSubExecution', {
i18n.baseText('runData.openSubExecutionWithId', {
interpolate: {
id: runMeta.subExecution?.executionId,
},

View file

@ -439,7 +439,7 @@ watch(focusedMappableInput, (curr) => {
>
<N8nTooltip
:content="
i18n.baseText('runData.table.inspectSubExecution', {
i18n.baseText('runData.table.viewSubExecution', {
interpolate: {
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
},
@ -575,7 +575,7 @@ watch(focusedMappableInput, (curr) => {
>
<N8nTooltip
:content="
i18n.baseText('runData.table.inspectSubExecution', {
i18n.baseText('runData.table.viewSubExecution', {
interpolate: {
id: `${tableData.metadata.data[index1]?.subExecution.executionId}`,
},

View file

@ -1,18 +1,14 @@
<script setup lang="ts">
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { codeNodeEditorEventBus } from '@/event-bus';
import { useExpressionEditor } from '@/composables/useExpressionEditor';
import { codeNodeEditorEventBus } from '@/event-bus';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import {
autocompleteKeyMap,
enterKeyMap,
historyKeyMap,
tabKeyMap,
} from '@/plugins/codemirror/keymap';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang';
import { ifNotIn } from '@codemirror/autocomplete';
import { history, toggleComment } from '@codemirror/commands';
import { history } from '@codemirror/commands';
import { LanguageSupport, bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { Prec, type Line } from '@codemirror/state';
import {
@ -34,10 +30,9 @@ import {
StandardSQL,
keywordCompletionSource,
} from '@n8n/codemirror-lang-sql';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { dropInExpressionEditor, mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { onClickOutside } from '@vueuse/core';
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue';
import { codeEditorTheme } from '../CodeNodeEditor/theme';
const SQL_DIALECTS = {
StandardSQL,
@ -87,7 +82,7 @@ const extensions = computed(() => {
const baseExtensions = [
sqlWithN8nLanguageSupport(),
expressionInputHandler(),
codeNodeEditorTheme({
codeEditorTheme({
isReadOnly: props.isReadOnly,
maxHeight: props.fullscreen ? '100%' : '40vh',
minHeight: '10vh',
@ -100,15 +95,7 @@ const extensions = computed(() => {
if (!props.isReadOnly) {
return baseExtensions.concat([
history(),
Prec.highest(
keymap.of([
...tabKeyMap(),
...enterKeyMap,
...historyKeyMap,
...autocompleteKeyMap,
{ key: 'Mod-/', run: toggleComment },
]),
),
Prec.highest(keymap.of(editorKeymap)),
n8nAutocompletion(),
indentOnInput(),
highlightActiveLine(),
@ -185,10 +172,10 @@ function line(lineNumber: number): Line | null {
}
}
function highlightLine(lineNumber: number | 'final') {
function highlightLine(lineNumber: number | 'last') {
if (!editor.value) return;
if (lineNumber === 'final') {
if (lineNumber === 'last') {
editor.value.dispatch({
selection: { anchor: editor.value.state.doc.length },
});

View file

@ -16,7 +16,10 @@ interface TagsDropdownProps {
allTags: ITag[];
isLoading: boolean;
tagsById: Record<string, ITag>;
createEnabled?: boolean;
manageEnabled?: boolean;
createTag?: (name: string) => Promise<ITag>;
multipleLimit?: number;
}
const i18n = useI18n();
@ -27,6 +30,10 @@ const props = withDefaults(defineProps<TagsDropdownProps>(), {
placeholder: '',
modelValue: () => [],
eventBus: null,
createEnabled: true,
manageEnabled: true,
createTag: undefined,
multipleLimit: 0,
});
const emit = defineEmits<{
@ -59,6 +66,17 @@ const appliedTags = computed<string[]>(() => {
return props.modelValue.filter((id: string) => props.tagsById[id]);
});
const containerClasses = computed(() => {
return { 'tags-container': true, focused: focused.value };
});
const dropdownClasses = computed(() => ({
'tags-dropdown': true,
[`tags-dropdown-${dropdownId}`]: true,
'tags-dropdown-create-enabled': props.createEnabled,
'tags-dropdown-manage-enabled': props.manageEnabled,
}));
watch(
() => props.allTags,
() => {
@ -189,7 +207,7 @@ onClickOutside(
</script>
<template>
<div ref="container" :class="{ 'tags-container': true, focused }" @keydown.stop>
<div ref="container" :class="containerClasses" @keydown.stop>
<N8nSelect
ref="selectRef"
:teleported="true"
@ -199,16 +217,17 @@ onClickOutside(
:filter-method="filterOptions"
filterable
multiple
:multiple-limit="props.multipleLimit"
:reserve-keyword="false"
loading-text="..."
:popper-class="['tags-dropdown', 'tags-dropdown-' + dropdownId].join(' ')"
:popper-class="dropdownClasses"
data-test-id="tags-dropdown"
@update:model-value="onTagsUpdated"
@visible-change="onVisibleChange"
@remove-tag="onRemoveTag"
>
<N8nOption
v-if="options.length === 0 && filter"
v-if="createEnabled && options.length === 0 && filter"
:key="CREATE_KEY"
ref="createRef"
:value="CREATE_KEY"
@ -220,7 +239,7 @@ onClickOutside(
</span>
</N8nOption>
<N8nOption v-else-if="options.length === 0" value="message" disabled>
<span>{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
<span v-if="createEnabled">{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
<span v-if="allTags.length > 0">{{
i18n.baseText('tagsDropdown.noMatchingTagsExist')
}}</span>
@ -237,7 +256,7 @@ onClickOutside(
data-test-id="tag"
/>
<N8nOption :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
<N8nOption v-if="manageEnabled" :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
<font-awesome-icon icon="cog" />
<span>{{ i18n.baseText('tagsDropdown.manageTags') }}</span>
</N8nOption>
@ -313,7 +332,7 @@ onClickOutside(
}
}
&:after {
.tags-dropdown-manage-enabled &:after {
content: ' ';
display: block;
min-height: $--item-height;

View file

@ -1,18 +1,15 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { EditableField } from '../types';
export interface EvaluationHeaderProps {
modelValue: {
value: string;
isEditing: boolean;
tempValue: string;
};
startEditing: (field: string) => void;
saveChanges: (field: string) => void;
handleKeydown: (e: KeyboardEvent, field: string) => void;
modelValue: EditableField<string>;
startEditing: (field: 'name') => void;
saveChanges: (field: 'name') => void;
handleKeydown: (e: KeyboardEvent, field: 'name') => void;
}
defineEmits<{ 'update:modelValue': [value: EvaluationHeaderProps['modelValue']] }>();
defineEmits<{ 'update:modelValue': [value: EditableField<string>] }>();
defineProps<EvaluationHeaderProps>();
const locale = useI18n();

View file

@ -2,12 +2,14 @@
import { useI18n } from '@/composables/useI18n';
import { ElCollapseTransition } from 'element-plus';
import { ref, nextTick } from 'vue';
import N8nTooltip from 'n8n-design-system/components/N8nTooltip';
interface EvaluationStep {
title: string;
warning?: boolean;
small?: boolean;
expanded?: boolean;
tooltip?: string;
}
const props = withDefaults(defineProps<EvaluationStep>(), {
@ -15,12 +17,14 @@ const props = withDefaults(defineProps<EvaluationStep>(), {
warning: false,
small: false,
expanded: true,
tooltip: '',
});
const locale = useI18n();
const isExpanded = ref(props.expanded);
const contentRef = ref<HTMLElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const isTooltipVisible = ref(false);
const toggleExpand = async () => {
isExpanded.value = !isExpanded.value;
@ -31,11 +35,32 @@ const toggleExpand = async () => {
}
}
};
const showTooltip = () => {
isTooltipVisible.value = true;
};
const hideTooltip = () => {
isTooltipVisible.value = false;
};
</script>
<template>
<div ref="containerRef" :class="[$style.evaluationStep, small && $style.small]">
<div :class="$style.content">
<div
ref="containerRef"
:class="[$style.evaluationStep, small && $style.small]"
data-test-id="evaluation-step"
>
<N8nTooltip :disabled="!tooltip" placement="right" :offset="25" :visible="isTooltipVisible">
<template #content>
{{ tooltip }}
</template>
<!-- This empty div is needed to ensure the tooltip trigger area spans the full width of the step.
Without it, the tooltip would only show when hovering over the content div, which is narrower.
The contentPlaceholder creates an invisible full-width area that can trigger the tooltip. -->
<div :class="$style.contentPlaceholder"></div>
</N8nTooltip>
<div :class="$style.content" @mouseenter="showTooltip" @mouseleave="hideTooltip">
<div :class="$style.header">
<div :class="[$style.icon, warning && $style.warning]">
<slot name="icon" />
@ -47,6 +72,7 @@ const toggleExpand = async () => {
:class="$style.collapseButton"
:aria-expanded="isExpanded"
:aria-controls="'content-' + title.replace(/\s+/g, '-')"
data-test-id="evaluation-step-collapse-button"
@click="toggleExpand"
>
{{
@ -59,7 +85,7 @@ const toggleExpand = async () => {
</div>
<ElCollapseTransition v-if="$slots.cardContent">
<div v-show="isExpanded" :class="$style.cardContentWrapper">
<div ref="contentRef" :class="$style.cardContent">
<div ref="contentRef" :class="$style.cardContent" data-test-id="evaluation-step-content">
<slot name="cardContent" />
</div>
</div>
@ -85,6 +111,14 @@ const toggleExpand = async () => {
width: 80%;
}
}
.contentPlaceholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
.icon {
display: flex;
align-items: center;

View file

@ -1,22 +1,30 @@
<script setup lang="ts">
import type { TestMetricRecord } from '@/api/testDefinition.ee';
import { useI18n } from '@/composables/useI18n';
export interface MetricsInputProps {
modelValue: string[];
modelValue: Array<Partial<TestMetricRecord>>;
}
const props = defineProps<MetricsInputProps>();
const emit = defineEmits<{ 'update:modelValue': [value: MetricsInputProps['modelValue']] }>();
const emit = defineEmits<{
'update:modelValue': [value: MetricsInputProps['modelValue']];
deleteMetric: [metric: Partial<TestMetricRecord>];
}>();
const locale = useI18n();
function addNewMetric() {
emit('update:modelValue', [...props.modelValue, '']);
emit('update:modelValue', [...props.modelValue, { name: '' }]);
}
function updateMetric(index: number, value: string) {
function updateMetric(index: number, name: string) {
const newMetrics = [...props.modelValue];
newMetrics[index] = value;
newMetrics[index].name = name;
emit('update:modelValue', newMetrics);
}
function onDeleteMetric(metric: Partial<TestMetricRecord>) {
emit('deleteMetric', metric);
}
</script>
<template>
@ -27,14 +35,15 @@ function updateMetric(index: number, value: string) {
:class="$style.metricField"
>
<div :class="$style.metricsContainer">
<div v-for="(metric, index) in modelValue" :key="index">
<div v-for="(metric, index) in modelValue" :key="index" :class="$style.metricItem">
<N8nInput
:ref="`metric_${index}`"
data-test-id="evaluation-metric-item"
:model-value="metric"
:model-value="metric.name"
:placeholder="locale.baseText('testDefinition.edit.metricsPlaceholder')"
@update:model-value="(value: string) => updateMetric(index, value)"
/>
<n8n-icon-button icon="trash" type="text" @click="onDeleteMetric(metric)" />
</div>
<n8n-button
type="tertiary"
@ -54,6 +63,11 @@ function updateMetric(index: number, value: string) {
gap: var(--spacing-xs);
}
.metricItem {
display: flex;
align-items: center;
}
.metricField {
width: 100%;
margin-top: var(--spacing-xs);

Some files were not shown because too many files have changed in this diff Show more