mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge remote-tracking branch 'origin/master' into pay-2440-bug-credential-testing-is-not-working
This commit is contained in:
commit
ada03278a2
4
.github/workflows/test-workflows.yml
vendored
4
.github/workflows/test-workflows.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
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');
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
"value": {},
|
||||
"matchingColumns": [],
|
||||
"schema": [],
|
||||
"ignoreTypeMismatchErrors": false,
|
||||
"attemptToConvertTypes": false,
|
||||
"convertFieldsToString": true
|
||||
},
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class BannerStack extends BasePage {
|
||||
getters = {
|
||||
banner: () => cy.getByTestId('banner-stack'),
|
||||
};
|
||||
|
||||
actions = {};
|
||||
}
|
|
@ -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 = {};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class SettingsUsagePage extends BasePage {
|
||||
url = '/settings/usage';
|
||||
|
||||
getters = {};
|
||||
|
||||
actions = {};
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class SettingsPage extends BasePage {
|
||||
url = '/settings';
|
||||
|
||||
getters = {
|
||||
menuItems: () => cy.getByTestId('menu-item'),
|
||||
};
|
||||
|
||||
actions = {};
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class WorkflowHistoryPage extends BasePage {
|
||||
getters = {
|
||||
workflowHistoryCloseButton: () => cy.getByTestId('workflow-history-close-button'),
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -229,8 +229,8 @@ describe('GlobalConfig', () => {
|
|||
maxPayload: 1024 * 1024 * 1024,
|
||||
port: 5679,
|
||||
maxOldSpaceSize: '',
|
||||
maxConcurrency: 5,
|
||||
taskTimeout: 60,
|
||||
maxConcurrency: 10,
|
||||
taskTimeout: 300,
|
||||
heartbeatInterval: 30,
|
||||
},
|
||||
sentry: {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 } },
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,12 +232,13 @@ 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) {
|
||||
try {
|
||||
// Fetch past execution with data
|
||||
const pastExecution = await this.executionRepository.findOne({
|
||||
where: { id: pastExecutionId },
|
||||
|
@ -248,8 +258,9 @@ export class TestRunnerService {
|
|||
);
|
||||
|
||||
// In case of a permission check issue, the test case execution will be undefined.
|
||||
// Skip them and continue with the next test case
|
||||
// Skip them, increment the failed count and continue with the next test case
|
||||
if (!testCaseExecution) {
|
||||
await this.testRunRepository.incrementFailed(testRun.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -264,11 +275,23 @@ export class TestRunnerService {
|
|||
evaluationWorkflow,
|
||||
originalRunData,
|
||||
testCaseRunData,
|
||||
testRun.id,
|
||||
);
|
||||
assert(evalExecution);
|
||||
|
||||
// Extract the output of the last node executed in the evaluation workflow
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const aggregatedMetrics = metrics.getAggregatedMetrics();
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)),
|
||||
);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -61,11 +61,7 @@ const validateResourceMapperValue = (
|
|||
});
|
||||
|
||||
if (!validationResult.valid) {
|
||||
if (!resourceMapperField.ignoreTypeMismatchErrors) {
|
||||
return { ...validationResult, fieldName: key };
|
||||
} else {
|
||||
paramValues[key] = resolvedValue;
|
||||
}
|
||||
} else {
|
||||
// If it's valid, set the casted value
|
||||
paramValues[key] = validationResult.newValue;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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%);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine);
|
||||
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;
|
||||
});
|
||||
|
||||
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied);
|
||||
|
||||
const { isReadOnly, language } = props;
|
||||
const extensions: Extension[] = [
|
||||
...readOnlyEditorExtensions,
|
||||
EditorState.readOnly.of(isReadOnly),
|
||||
EditorView.editable.of(!isReadOnly),
|
||||
codeNodeEditorTheme({
|
||||
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,
|
||||
}),
|
||||
];
|
||||
|
||||
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;
|
||||
},
|
||||
}),
|
||||
onChange: onEditorUpdate,
|
||||
});
|
||||
|
||||
EditorView.updateListener.of((viewUpdate) => {
|
||||
if (!viewUpdate.docChanged) return;
|
||||
onMounted(() => {
|
||||
if (!props.isReadOnly) codeNodeEditorEventBus.on('highlightLine', highlightLine);
|
||||
codeNodeEditorEventBus.on('codeDiffApplied', diffApplied);
|
||||
|
||||
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,88 +101,11 @@ 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.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) {
|
||||
watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => {
|
||||
if (readEditorValue().trim() === CODE_PLACEHOLDERS[prevLanguage]?.[prevMode]) {
|
||||
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
|
||||
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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 },
|
||||
]),
|
||||
),
|
||||
];
|
|
@ -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.
|
||||
|
|
|
@ -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,18 +17,18 @@ 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 undefined;
|
||||
return codeMirrorLinter(lintSource, { delay: DEFAULT_LINTER_DELAY_IN_MS });
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
function lintSource(editorView: EditorView): Diagnostic[] {
|
||||
const doc = editorView.state.doc.toString();
|
||||
const script = `module.exports = async function() {${doc}\n}()`;
|
||||
|
@ -38,36 +38,9 @@ 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
if (ast === null) return [];
|
||||
|
||||
const lintings: Diagnostic[] = [];
|
||||
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,12 +100,16 @@ 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)' } : {}),
|
||||
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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">
|
||||
<n8n-text>
|
||||
{{
|
||||
i18n.baseText('freeAi.credits.callout.success.title', {
|
||||
i18n.baseText('freeAi.credits.callout.success.title.part1', {
|
||||
interpolate: { credits: settingsStore.aiCreditsQuota },
|
||||
})
|
||||
}}
|
||||
}}</n8n-text
|
||||
>
|
||||
<n8n-text :bold="true">
|
||||
{{ i18n.baseText('freeAi.credits.callout.success.title.part2') }}</n8n-text
|
||||
>
|
||||
</n8n-callout>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -97,7 +97,7 @@ onMounted(() => {
|
|||
extensions: [
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.domEventHandlers({ scroll: forceParse }),
|
||||
EditorView.domEventHandlers({ scroll: (_, view) => forceParse(view) }),
|
||||
...props.extensions,
|
||||
],
|
||||
}),
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -1263,10 +1263,14 @@ function getExecutionLinkLabel(task: ITaskMetadata): string | undefined {
|
|||
}
|
||||
|
||||
if (task.subExecution) {
|
||||
return i18n.baseText('runData.openSubExecution', {
|
||||
if (activeTaskMetadata.value?.subExecutionsCount === 1) {
|
||||
return i18n.baseText('runData.openSubExecutionSingle');
|
||||
} else {
|
||||
return i18n.baseText('runData.openSubExecutionWithId', {
|
||||
interpolate: { id: task.subExecution.executionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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}`,
|
||||
},
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue