feat(editor): Unify regular and trigger node creator panels (#5315)

* WIP: Merge TriggerHelperPanel with MainPanel

* WIP: Implement switching between views

* Remove logging

* WIP: Rework search

* Fix category toggling and search results display

* Fix node item description

* Sort actions based on the root view

* Adjust personalisation modal, make trigger canvas node round

* Linting fixes

* Fix filtering of API options

* Fix types and no result state

* Cleanup

* Linting fixes

* Adjust mode prop for node creator tracking

* Fix merging of core nodes and filtering of single placeholder actions

* Lint fixes

* Implement actions override, fix node creator view item spacing and increase click radius of trigger node icon

* Fix keyboard view navigation

* WIP: E2E Tests

* Address product review

* Minor fixes & cleanup

* Fix tests

* Some more test fixes

* Add specs to check actions and panels

* Update personalisation survey snapshot
This commit is contained in:
OlegIvaniv 2023-02-17 15:08:26 +01:00 committed by GitHub
parent 561882f599
commit 9a1e7b52f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1187 additions and 1339 deletions

View file

@ -10,7 +10,7 @@ describe('Inline expression editor', () => {
beforeEach(() => { beforeEach(() => {
WorkflowPage.actions.visit(); WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Hacker News'); WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openInlineExpressionEditor(); WorkflowPage.actions.openInlineExpressionEditor();
@ -50,6 +50,7 @@ describe('Inline expression editor', () => {
}); });
it('should resolve array resolvables', () => { it('should resolve array resolvables', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{'); WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('[1, 2, 3]'); WorkflowPage.getters.inlineExpressionEditorInput().type('[1, 2, 3]');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^\[Array: \[1,2,3\]\]$/); WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^\[Array: \[1,2,3\]\]$/);
@ -63,8 +64,9 @@ describe('Inline expression editor', () => {
}); });
it('should resolve $parameter[]', () => { it('should resolve $parameter[]', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{'); WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('$parameter["operation"]'); WorkflowPage.getters.inlineExpressionEditorInput().type('$parameter["operation"]');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^get$/); WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^getAll$/);
}); });
}); });

View file

@ -96,6 +96,7 @@ describe('Canvas Actions', () => {
it('should add merge node and test connections', () => { it('should add merge node and test connections', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true); WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true);
WorkflowPage.getters.nodeViewBackground().click(600 + i * 100, 200, { force: true }); WorkflowPage.getters.nodeViewBackground().click(600 + i * 100, 200, { force: true });
@ -138,6 +139,7 @@ describe('Canvas Actions', () => {
it('should add nodes and check execution success', () => { it('should add nodes and check execution success', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true); WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true);
} }
@ -235,6 +237,7 @@ describe('Canvas Actions', () => {
it('should move node', () => { it('should move node', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit(); WorkflowPage.actions.zoomToFit();
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150]); cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150]);
@ -316,6 +319,7 @@ describe('Canvas Actions', () => {
it('should select nodes using arrow keys', () => { it('should select nodes using arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500); cy.wait(500);
cy.get('body').type('{leftArrow}'); cy.get('body').type('{leftArrow}');
@ -326,6 +330,7 @@ describe('Canvas Actions', () => {
it('should select nodes using shift and arrow keys', () => { it('should select nodes using shift and arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500); cy.wait(500);
cy.get('body').type('{shift}', { release: false }).type('{leftArrow}'); cy.get('body').type('{shift}', { release: false }).type('{leftArrow}');
@ -334,6 +339,7 @@ describe('Canvas Actions', () => {
it('should delete connections by pressing the delete button', () => { it('should delete connections by pressing the delete button', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().realHover(); WorkflowPage.getters.nodeConnections().first().realHover();
cy.get('.connection-actions .delete').first().click({ force: true }); cy.get('.connection-actions .delete').first().click({ force: true });
@ -342,6 +348,7 @@ describe('Canvas Actions', () => {
it('should delete a connection by moving it away from endpoint', () => { it('should delete a connection by moving it away from endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.drag(WorkflowPage.getters.getEndpointSelector('input', CODE_NODE_NAME), [0, -100]); cy.drag(WorkflowPage.getters.getEndpointSelector('input', CODE_NODE_NAME), [0, -100]);
WorkflowPage.getters.nodeConnections().should('have.length', 0); WorkflowPage.getters.nodeConnections().should('have.length', 0);
@ -349,6 +356,7 @@ describe('Canvas Actions', () => {
it('should disable node by pressing the disable button', () => { it('should disable node by pressing the disable button', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters WorkflowPage.getters
.canvasNodes() .canvasNodes()
@ -360,6 +368,7 @@ describe('Canvas Actions', () => {
it('should disable node using keyboard shortcut', () => { it('should disable node using keyboard shortcut', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().last().click(); WorkflowPage.getters.canvasNodes().last().click();
WorkflowPage.actions.hitDisableNodeShortcut(); WorkflowPage.actions.hitDisableNodeShortcut();
@ -368,6 +377,7 @@ describe('Canvas Actions', () => {
it('should disable multiple nodes', () => { it('should disable multiple nodes', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.get('body').type('{esc}'); cy.get('body').type('{esc}');
cy.get('body').type('{esc}'); cy.get('body').type('{esc}');
@ -389,6 +399,7 @@ describe('Canvas Actions', () => {
it('should duplicate node', () => { it('should duplicate node', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters WorkflowPage.getters
.canvasNodes() .canvasNodes()

View file

@ -12,8 +12,7 @@ describe('Data pinning', () => {
}); });
it('Should be able to pin node output', () => { it('Should be able to pin node output', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger'); workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true});
workflowPage.getters.canvasNodes().first().dblclick();
ndv.getters.container().should('be.visible'); ndv.getters.container().should('be.visible');
ndv.getters.pinDataButton().should('not.exist'); ndv.getters.pinDataButton().should('not.exist');
ndv.getters.editPinnedDataButton().should('be.visible'); ndv.getters.editPinnedDataButton().should('be.visible');
@ -21,7 +20,9 @@ describe('Data pinning', () => {
ndv.actions.execute(); ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible'); ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.outputDataContainer().get('table').should('be.visible'); // We hover over the table to get rid of the pinning tooltip which would overlay the table
// slightly and cause the test to fail
ndv.getters.outputDataContainer().get('table').realHover().should('be.visible');
ndv.getters.outputTableRows().should('have.length', 2); ndv.getters.outputTableRows().should('have.length', 2);
ndv.getters.outputTableHeaders().should('have.length.at.least', 10); ndv.getters.outputTableHeaders().should('have.length.at.least', 10);
ndv.getters.outputTableHeaders().first().should('include.text', 'timestamp'); ndv.getters.outputTableHeaders().first().should('include.text', 'timestamp');
@ -42,8 +43,7 @@ describe('Data pinning', () => {
}); });
it('Should be be able to set pinned data', () => { it('Should be be able to set pinned data', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger'); workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true});
workflowPage.getters.canvasNodes().first().dblclick();
ndv.getters.container().should('be.visible'); ndv.getters.container().should('be.visible');
ndv.getters.pinDataButton().should('not.exist'); ndv.getters.pinDataButton().should('not.exist');
ndv.getters.editPinnedDataButton().should('be.visible'); ndv.getters.editPinnedDataButton().should('be.visible');

View file

@ -26,7 +26,8 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input); ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.actions.execute(); ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible').contains(output); ndv.getters.outputDataContainer().should('be.visible')
ndv.getters.outputDataContainer().contains(output);
}); });
it('$json + n8n string methods', () => { it('$json + n8n string methods', () => {
@ -40,7 +41,8 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input); ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.actions.execute(); ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible').contains(output); ndv.getters.outputDataContainer().should('be.visible')
ndv.getters.outputDataContainer().contains(output);
}); });
it('$json + native numeric methods', () => { it('$json + native numeric methods', () => {
@ -54,7 +56,8 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input); ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.actions.execute(); ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible').contains(output); ndv.getters.outputDataContainer().should('be.visible')
ndv.getters.outputDataContainer().contains(output);
}); });
it('$json + n8n numeric methods', () => { it('$json + n8n numeric methods', () => {
@ -68,7 +71,8 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input); ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.actions.execute(); ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible').contains(output); ndv.getters.outputDataContainer().should('be.visible')
ndv.getters.outputDataContainer().contains(output);
}); });
it('$json + native array methods', () => { it('$json + native array methods', () => {
@ -82,7 +86,8 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input); ndv.getters.inlineExpressionEditorInput().clear().type(input);
ndv.actions.execute(); ndv.actions.execute();
ndv.getters.outputDataContainer().should('be.visible').contains(output); ndv.getters.outputDataContainer().should('be.visible')
ndv.getters.outputDataContainer().contains(output);
}); });
it('$json + n8n array methods', () => { it('$json + n8n array methods', () => {

View file

@ -1,8 +1,12 @@
import { WorkflowPage, NDV, CanvasNode } from '../pages'; import {
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from './../constants';
import { WorkflowPage, NDV } from '../pages';
const workflowPage = new WorkflowPage(); const workflowPage = new WorkflowPage();
const ndv = new NDV(); const ndv = new NDV();
const canvasNode = new CanvasNode();
describe('Data mapping', () => { describe('Data mapping', () => {
beforeEach(() => { beforeEach(() => {
@ -20,7 +24,7 @@ describe('Data mapping', () => {
cy.fixture('Test_workflow-actions_paste-data.json').then((data) => { cy.fixture('Test_workflow-actions_paste-data.json').then((data) => {
cy.get('body').paste(JSON.stringify(data)); cy.get('body').paste(JSON.stringify(data));
}); });
canvasNode.actions.openNode('Set'); workflowPage.actions.openNode('Set');
ndv.actions.executePrevious(); ndv.actions.executePrevious();
ndv.actions.switchInputMode('Table'); ndv.actions.switchInputMode('Table');
ndv.getters.inputDataContainer().get('table', { timeout: 10000 }).should('exist'); ndv.getters.inputDataContainer().get('table', { timeout: 10000 }).should('exist');
@ -42,7 +46,7 @@ describe('Data mapping', () => {
cy.get('body').paste(JSON.stringify(data)); cy.get('body').paste(JSON.stringify(data));
}); });
canvasNode.actions.openNode('Set'); workflowPage.actions.openNode('Set');
ndv.actions.switchInputMode('Table'); ndv.actions.switchInputMode('Table');
ndv.getters.inputDataContainer().get('table', { timeout: 10000 }).should('exist'); ndv.getters.inputDataContainer().get('table', { timeout: 10000 }).should('exist');
@ -87,7 +91,7 @@ describe('Data mapping', () => {
cy.get('body').paste(JSON.stringify(data)); cy.get('body').paste(JSON.stringify(data));
}); });
canvasNode.actions.openNode('Set'); workflowPage.actions.openNode('Set');
ndv.actions.switchInputMode('JSON'); ndv.actions.switchInputMode('JSON');
ndv.getters.inputDataContainer().should('exist').find('.json-data') ndv.getters.inputDataContainer().should('exist').find('.json-data')
@ -115,7 +119,7 @@ describe('Data mapping', () => {
cy.get('body').paste(JSON.stringify(data)); cy.get('body').paste(JSON.stringify(data));
}); });
canvasNode.actions.openNode('Set'); workflowPage.actions.openNode('Set');
ndv.actions.clearParameterInput('value'); ndv.actions.clearParameterInput('value');
cy.get('body').type('{esc}'); cy.get('body').type('{esc}');
@ -142,22 +146,22 @@ describe('Data mapping', () => {
it('maps expressions from previous nodes', () => { it('maps expressions from previous nodes', () => {
cy.createFixtureWorkflow('Test_workflow_3.json', `My test workflow`); cy.createFixtureWorkflow('Test_workflow_3.json', `My test workflow`);
canvasNode.actions.openNode('Set1'); workflowPage.actions.openNode('Set1');
ndv.actions.selectInputNode('Schedule Trigger'); ndv.actions.selectInputNode(SCHEDULE_TRIGGER_NODE_NAME);
ndv.getters.inputDataContainer() ndv.getters.inputDataContainer()
.find('span').contains('count') .find('span').contains('count')
.realMouseDown(); .realMouseDown();
ndv.actions.mapToParameter('value'); ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $node["Schedule Trigger"].json.input[0].count }}'); ndv.getters.inlineExpressionEditorInput().should('have.text', `{{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input[0].count }}`);
ndv.getters.parameterExpressionPreview('value') ndv.getters.parameterExpressionPreview('value')
.should('not.exist'); .should('not.exist');
ndv.actions.switchInputMode('Table'); ndv.actions.switchInputMode('Table');
ndv.actions.mapDataFromHeader(1, 'value'); ndv.actions.mapDataFromHeader(1, 'value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $node["Schedule Trigger"].json.input[0].count }} {{ $node["Schedule Trigger"].json.input }}'); ndv.getters.inlineExpressionEditorInput().should('have.text', `{{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input[0].count }} {{ $node["${SCHEDULE_TRIGGER_NODE_NAME}"].json.input }}`);
ndv.getters.parameterExpressionPreview('value') ndv.getters.parameterExpressionPreview('value')
.should('not.exist'); .should('not.exist');
@ -175,8 +179,9 @@ describe('Data mapping', () => {
}); });
it('maps keys to path', () => { it('maps keys to path', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger', {keepNdvOpen: true}); workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
workflowPage.actions.openNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
ndv.actions.setPinnedData([ ndv.actions.setPinnedData([
{ {
input: [ input: [
@ -201,7 +206,7 @@ describe('Data mapping', () => {
ndv.actions.close(); ndv.actions.close();
workflowPage.actions.addNodeToCanvas('Item Lists'); workflowPage.actions.addNodeToCanvas('Item Lists');
canvasNode.actions.openNode('Item Lists'); workflowPage.actions.openNode('Item Lists');
ndv.getters.parameterInput('operation') ndv.getters.parameterInput('operation')
.click() .click()

View file

@ -253,7 +253,7 @@ describe('Credentials', () => {
it('should render custom node with n8n credential', () => { it('should render custom node with n8n credential', () => {
workflowPage.actions.visit(); workflowPage.actions.visit();
workflowPage.actions.addNodeToCanvas('Manual Trigger'); workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true); workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click(); workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click(); cy.contains('Create New Credential').click();
@ -263,7 +263,7 @@ describe('Credentials', () => {
it('should render custom node with custom credential', () => { it('should render custom node with custom credential', () => {
workflowPage.actions.visit(); workflowPage.actions.visit();
workflowPage.actions.addNodeToCanvas('Manual Trigger'); workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true); workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click(); workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click(); cy.contains('Create New Credential').click();

View file

@ -1,13 +1,16 @@
import { NodeCreator } from '../pages/features/node-creator'; import { NodeCreator } from '../pages/features/node-creator';
import { INodeTypeDescription } from 'n8n-workflow';
import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants'; import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants';
import { randFirstName, randLastName } from '@ngneat/falso'; import { randFirstName, randLastName } from '@ngneat/falso';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { NDV } from '../pages/ndv';
const email = DEFAULT_USER_EMAIL; const email = DEFAULT_USER_EMAIL;
const password = DEFAULT_USER_PASSWORD; const password = DEFAULT_USER_PASSWORD;
const firstName = randFirstName(); const firstName = randFirstName();
const lastName = randLastName(); const lastName = randLastName();
const nodeCreatorFeature = new NodeCreator(); const nodeCreatorFeature = new NodeCreator();
const WorkflowPage = new WorkflowPageClass();
const NDVModal = new NDV();
describe('Node Creator', () => { describe('Node Creator', () => {
before(() => { before(() => {
@ -27,30 +30,22 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters nodeCreatorFeature.getters
.nodeCreator() .nodeCreator()
.contains('When should this workflow run?') .contains('Select a trigger')
.should('be.visible'); .should('be.visible');
nodeCreatorFeature.getters.nodeCreatorTabs().should('not.exist');
});
it('should see all tabs when opening via plus button', () => {
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.nodeCreatorTabs().should('exist');
nodeCreatorFeature.getters.selectedTab().should('have.text', 'Trigger');
}); });
it('should navigate subcategory', () => { it('should navigate subcategory', () => {
nodeCreatorFeature.actions.openNodeCreator(); nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.getCreatorItem('On App Event').click(); nodeCreatorFeature.getters.getCreatorItem('On app event').click();
nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'On App Event'); nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'On app event');
// Go back // Go back
nodeCreatorFeature.getters.activeSubcategory().find('button').click(); nodeCreatorFeature.getters.activeSubcategory().find('button').click();
nodeCreatorFeature.getters.activeSubcategory().should('not.exist'); nodeCreatorFeature.getters.activeSubcategory().should('not.have.text', 'On app event');
}); });
it('should search for nodes', () => { it('should search for nodes', () => {
nodeCreatorFeature.actions.openNodeCreator(); nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.selectedTab().should('have.text', 'Trigger');
nodeCreatorFeature.getters.searchBar().find('input').type('manual'); nodeCreatorFeature.getters.searchBar().find('input').type('manual');
nodeCreatorFeature.getters.creatorItem().should('have.length', 1); nodeCreatorFeature.getters.creatorItem().should('have.length', 1);
@ -72,92 +67,63 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.creatorItem().should('have.length', 0); nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
nodeCreatorFeature.getters.searchBar().find('input').clear(); nodeCreatorFeature.getters.searchBar().find('input').clear();
nodeCreatorFeature.getters.getCreatorItem('On App Event').click(); nodeCreatorFeature.getters.getCreatorItem('On app event').click();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image');
nodeCreatorFeature.getters.creatorItem().should('have.length', 0); nodeCreatorFeature.getters.getCreatorItem('Results in other categories (1)').should('exist');
nodeCreatorFeature.getters nodeCreatorFeature.getters.creatorItem().should('have.length', 2);
.noResults()
.should('exist')
.should('contain.text', 'To see all results, click here');
nodeCreatorFeature.getters.noResults().contains('click here').click();
nodeCreatorFeature.getters.nodeCreatorTabs().should('exist');
nodeCreatorFeature.getters.getCreatorItem('Edit Image').should('exist'); nodeCreatorFeature.getters.getCreatorItem('Edit Image').should('exist');
nodeCreatorFeature.getters.selectedTab().should('have.text', 'All'); nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image123123');
nodeCreatorFeature.getters.searchBar().find('button').click(); nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
nodeCreatorFeature.getters.searchBar().find('input').should('be.empty');
}); });
it('should add manual trigger node', () => { it('should check correct view panels', () => {
nodeCreatorFeature.getters.canvasAddButton().click(); nodeCreatorFeature.getters.canvasAddButton().click();
nodeCreatorFeature.getters.getCreatorItem('Manually').click(); WorkflowPage.actions.addNodeToCanvas('Manual', false);
// TODO: Replace once we have canvas feature utils
cy.get('span').contains('Back to canvas').click();
nodeCreatorFeature.getters.canvasAddButton().should('not.be.visible'); nodeCreatorFeature.getters.canvasAddButton().should('not.be.visible');
nodeCreatorFeature.getters.nodeCreator().should('not.exist'); nodeCreatorFeature.getters.nodeCreator().should('not.exist');
// TODO: Replace once we have canvas feature utils // TODO: Replace once we have canvas feature utils
cy.get('div').contains('Add first step').should('exist'); cy.get('div').contains('Add first step').should('be.hidden');
nodeCreatorFeature.actions.openNodeCreator()
nodeCreatorFeature.getters
.nodeCreator()
.contains('What happens next?')
.should('be.visible');
nodeCreatorFeature.getters.getCreatorItem('Add another trigger').click();
nodeCreatorFeature.getters.nodeCreator().contains('Select a trigger').should('be.visible');
nodeCreatorFeature.getters.activeSubcategory().find('button').should('exist');
nodeCreatorFeature.getters.activeSubcategory().find('button').click();
nodeCreatorFeature.getters
.nodeCreator()
.contains('What happens next?')
.should('be.visible');
}); });
it('check if non-core nodes are rendered', () => {
cy.wait('@nodesIntercept').then((interception) => {
const nodes = interception.response?.body as INodeTypeDescription[];
const categorizedNodes = nodeCreatorFeature.actions.categorizeNodes(nodes); it('should add node to canvas from actions panel', () => {
nodeCreatorFeature.actions.openNodeCreator(); const editImageNode = 'Edit Image';
nodeCreatorFeature.actions.selectTab('All'); nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type(editImageNode);
const categories = Object.keys(categorizedNodes); nodeCreatorFeature.getters.getCreatorItem(editImageNode).click();
categories.forEach((category: string) => { nodeCreatorFeature.getters.activeSubcategory().should('have.text', editImageNode);
// Core Nodes contains subcategories which we'll test separately nodeCreatorFeature.getters.getCreatorItem('Crop Image').click();
if (category === 'Core Nodes') return; NDVModal.getters.parameterInput('operation').should('contain.text', 'Crop');
})
nodeCreatorFeature.actions.toggleCategory(category);
// Check if all nodes are present
nodeCreatorFeature.getters.nodeItemName().then(($elements) => {
const visibleNodes: string[] = [];
$elements.each((_, element) => {
visibleNodes.push(element.textContent?.trim() || '');
});
const visibleCategoryNodes = (categorizedNodes[category] as INodeTypeDescription[])
.filter((node) => !node.hidden)
.map((node) => node.displayName?.trim());
cy.wrap(visibleCategoryNodes).each((categoryNode: string) => {
expect(visibleNodes).to.include(categoryNode);
});
});
nodeCreatorFeature.actions.toggleCategory(category);
});
});
});
it('should render and select community node', () => { it('should render and select community node', () => {
cy.intercept('GET', '/types/nodes.json').as('nodesIntercept'); cy.intercept('GET', '/types/nodes.json').as('nodesIntercept');
cy.wait('@nodesIntercept').then(() => { cy.wait('@nodesIntercept').then(() => {
const customCategory = 'Custom Category';
const customNode = 'E2E Node'; const customNode = 'E2E Node';
const customNodeDescription = 'Demonstrate rendering of node';
nodeCreatorFeature.actions.openNodeCreator(); nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.actions.selectTab('All'); nodeCreatorFeature.getters.searchBar().find('input').clear().type(customNode);
nodeCreatorFeature.getters.getCreatorItem(customCategory).should('exist');
nodeCreatorFeature.actions.toggleCategory(customCategory);
nodeCreatorFeature.getters nodeCreatorFeature.getters
.getCreatorItem(customNode) .getCreatorItem(customNode)
.findChildByTestId('node-creator-item-tooltip') .findChildByTestId('node-creator-item-tooltip')
.should('exist'); .should('exist');
nodeCreatorFeature.getters
.getCreatorItem(customNode)
.contains(customNodeDescription)
.should('exist');
nodeCreatorFeature.actions.selectNode(customNode); nodeCreatorFeature.actions.selectNode(customNode);
// TODO: Replace once we have canvas feature utils // TODO: Replace once we have canvas feature utils

View file

@ -17,7 +17,7 @@ describe('NDV', () => {
it('should show up when double clicked on a node and close when Back to canvas clicked', () => { it('should show up when double clicked on a node and close when Back to canvas clicked', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.getters.canvasNodes().first().dblclick(); workflowPage.getters.canvasNodes().first().dblclick();
ndv.getters.container().should('be.visible'); ndv.getters.container().should('be.visible');
ndv.getters.backToCanvas().click(); ndv.getters.backToCanvas().click();
@ -67,8 +67,8 @@ describe('NDV', () => {
}); });
it('should show validation errors only after blur or re-opening of NDV', () => { it('should show validation errors only after blur or re-opening of NDV', () => {
workflowPage.actions.addNodeToCanvas('Manual Trigger'); workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Airtable', true, true); workflowPage.actions.addNodeToCanvas('Airtable', true, true, 'Read data from a table');
ndv.getters.container().should('be.visible'); ndv.getters.container().should('be.visible');
cy.get('.has-issues').should('have.length', 0); cy.get('.has-issues').should('have.length', 0);
ndv.getters.parameterInput('table').find('input').eq(1).focus().blur(); ndv.getters.parameterInput('table').find('input').eq(1).focus().blur();

View file

@ -12,7 +12,7 @@ describe('Code node', () => {
it('should execute the placeholder in all-items mode successfully', () => { it('should execute the placeholder in all-items mode successfully', () => {
WorkflowPage.actions.visit(); WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Code'); WorkflowPage.actions.addNodeToCanvas('Code');
WorkflowPage.actions.openNode('Code'); WorkflowPage.actions.openNode('Code');
@ -23,7 +23,7 @@ describe('Code node', () => {
it('should execute the placeholder in each-item mode successfully', () => { it('should execute the placeholder in each-item mode successfully', () => {
WorkflowPage.actions.visit(); WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Code'); WorkflowPage.actions.addNodeToCanvas('Code');
WorkflowPage.actions.openNode('Code'); WorkflowPage.actions.openNode('Code');
ndv.getters.parameterInput('mode').click(); ndv.getters.parameterInput('mode').click();

View file

@ -14,7 +14,7 @@ describe('HTTP Request node', () => {
cy.visit(workflowsPage.url); cy.visit(workflowsPage.url);
workflowsPage.actions.createWorkflowFromCard(); workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('HTTP Request'); workflowPage.actions.addNodeToCanvas('HTTP Request');
workflowPage.actions.openNode('HTTP Request'); workflowPage.actions.openNode('HTTP Request');
ndv.actions.typeIntoParameterInput('url', 'https://catfact.ninja/fact'); ndv.actions.typeIntoParameterInput('url', 'https://catfact.ninja/fact');

View file

@ -10,13 +10,14 @@ describe('Expression editor modal', () => {
beforeEach(() => { beforeEach(() => {
WorkflowPage.actions.visit(); WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual Trigger'); WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Hacker News'); WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.openNode('Hacker News'); WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openExpressionEditorModal(); WorkflowPage.actions.openExpressionEditorModal();
}); });
it('should resolve primitive resolvables', () => { it('should resolve primitive resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ 1 + 2'); WorkflowPage.getters.expressionModalInput().type('{{ 1 + 2');
WorkflowPage.getters.expressionModalOutput().contains(/^3$/); WorkflowPage.getters.expressionModalOutput().contains(/^3$/);
WorkflowPage.getters.expressionModalInput().clear(); WorkflowPage.getters.expressionModalInput().clear();
@ -31,6 +32,7 @@ describe('Expression editor modal', () => {
}); });
it('should resolve object resolvables', () => { it('should resolve object resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters WorkflowPage.getters
.expressionModalInput() .expressionModalInput()
.type('{{ { a : 1 }', { parseSpecialCharSequences: false }); .type('{{ { a : 1 }', { parseSpecialCharSequences: false });
@ -45,6 +47,7 @@ describe('Expression editor modal', () => {
}); });
it('should resolve array resolvables', () => { it('should resolve array resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ [1, 2, 3]'); WorkflowPage.getters.expressionModalInput().type('{{ [1, 2, 3]');
WorkflowPage.getters.expressionModalOutput().contains(/^\[Array: \[1,2,3\]\]$/); WorkflowPage.getters.expressionModalOutput().contains(/^\[Array: \[1,2,3\]\]$/);
@ -55,7 +58,8 @@ describe('Expression editor modal', () => {
}); });
it('should resolve $parameter[]', () => { it('should resolve $parameter[]', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ $parameter["operation"]'); WorkflowPage.getters.expressionModalInput().type('{{ $parameter["operation"]');
WorkflowPage.getters.expressionModalOutput().contains(/^get$/); WorkflowPage.getters.expressionModalOutput().contains(/^getAll$/);
}); });
}); });

View file

@ -1,15 +0,0 @@
import { BasePage } from './base';
export class CanvasNode extends BasePage {
getters = {
nodes: () => cy.getByTestId('canvas-node'),
nodeByName: (nodeName: string) =>
this.getters.nodes().filter(`:contains("${nodeName}")`),
};
actions = {
openNode: (nodeName: string) => {
this.getters.nodeByName(nodeName).eq(0).dblclick();
},
};
}

View file

@ -31,9 +31,6 @@ export class NodeCreator extends BasePage {
selectNode: (displayName: string) => { selectNode: (displayName: string) => {
this.getters.getCreatorItem(displayName).click(); this.getters.getCreatorItem(displayName).click();
}, },
selectTab: (tab: string) => {
this.getters.nodeCreatorTabs().contains(tab).click();
},
toggleCategory: (category: string) => { toggleCategory: (category: string) => {
this.getters.getCreatorItem(category).click(); this.getters.getCreatorItem(category).click();
}, },

View file

@ -9,4 +9,3 @@ export * from './settings-users';
export * from './settings-log-streaming'; export * from './settings-log-streaming';
export * from './sidebar'; export * from './sidebar';
export * from './ndv'; export * from './ndv';
export * from './canvas-node';

View file

@ -19,7 +19,7 @@ export class NDV extends BasePage {
digital: () => cy.getByTestId('ndv-run-data-display-mode'), digital: () => cy.getByTestId('ndv-run-data-display-mode'),
pinDataButton: () => cy.getByTestId('ndv-pin-data'), pinDataButton: () => cy.getByTestId('ndv-pin-data'),
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'), editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
pinnedDataEditor: () => this.getters.outputPanel().find('.monaco-editor'), pinnedDataEditor: () => this.getters.outputPanel().find('.monaco-editor[role=code]'),
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'), runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
savePinnedDataButton: () => this.getters.runDataPaneHeader().find('button').contains('Save'), savePinnedDataButton: () => this.getters.runDataPaneHeader().find('button').contains('Save'),
outputTableRows: () => this.getters.outputDataContainer().find('table tr'), outputTableRows: () => this.getters.outputDataContainer().find('table tr'),
@ -71,10 +71,9 @@ export class NDV extends BasePage {
setPinnedData: (data: object) => { setPinnedData: (data: object) => {
this.getters.editPinnedDataButton().click(); this.getters.editPinnedDataButton().click();
const editor = this.getters.pinnedDataEditor(); this.getters.pinnedDataEditor().click();
editor.click(); this.getters.pinnedDataEditor().type(`{selectall}{backspace}`);
editor.type(`{selectall}{backspace}`); this.getters.pinnedDataEditor().type(JSON.stringify(data).replace(new RegExp('{', 'g'), '{{}'));
editor.type(JSON.stringify(data).replace(new RegExp('{', 'g'), '{{}'));
this.actions.savePinnedData(); this.actions.savePinnedData();
}, },

View file

@ -24,7 +24,7 @@ export class WorkflowPage extends BasePage {
canvasPlusButton: () => cy.getByTestId('canvas-plus-button'), canvasPlusButton: () => cy.getByTestId('canvas-plus-button'),
canvasNodes: () => cy.getByTestId('canvas-node'), canvasNodes: () => cy.getByTestId('canvas-node'),
canvasNodeByName: (nodeName: string) => canvasNodeByName: (nodeName: string) =>
this.getters.canvasNodes().filter(`:contains("${nodeName}")`), this.getters.canvasNodes().filter(`:contains(${nodeName})`),
getEndpointSelector: (type: 'input' | 'output' | 'plus', nodeName: string, index = 0) => { getEndpointSelector: (type: 'input' | 'output' | 'plus', nodeName: string, index = 0) => {
return `[data-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']`; return `[data-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']`;
}, },
@ -124,6 +124,7 @@ export class WorkflowPage extends BasePage {
nodeDisplayName: string, nodeDisplayName: string,
plusButtonClick = true, plusButtonClick = true,
preventNdvClose?: boolean, preventNdvClose?: boolean,
action?: string,
) => { ) => {
if (plusButtonClick) { if (plusButtonClick) {
this.getters.nodeCreatorPlusButton().click(); this.getters.nodeCreatorPlusButton().click();
@ -131,11 +132,21 @@ export class WorkflowPage extends BasePage {
this.getters.nodeCreatorSearchBar().type(nodeDisplayName); this.getters.nodeCreatorSearchBar().type(nodeDisplayName);
this.getters.nodeCreatorSearchBar().type('{enter}'); this.getters.nodeCreatorSearchBar().type('{enter}');
cy.wait(500)
cy.get('body').then((body) => {
if(body.find('[data-test-id=node-creator]').length > 0) {
if(action) {
cy.contains(action).click()
} else {
cy.getByTestId('item-iterator-item').eq(1).click()
}
}
})
if (!preventNdvClose) cy.get('body').type('{esc}'); if (!preventNdvClose) cy.get('body').type('{esc}');
}, },
openNode: (nodeTypeName: string) => { openNode: (nodeTypeName: string) => {
this.getters.canvasNodeByName(nodeTypeName).dblclick(); this.getters.canvasNodeByName(nodeTypeName).first().dblclick();
}, },
openExpressionEditorModal: () => { openExpressionEditorModal: () => {
cy.contains('Expression').invoke('show').click(); cy.contains('Expression').invoke('show').click();

View file

@ -13,7 +13,7 @@
<div> <div>
<div :class="$style.details"> <div :class="$style.details">
<span :class="$style.name" v-text="title" data-test-id="node-creator-item-name" /> <span :class="$style.name" v-text="title" data-test-id="node-creator-item-name" />
<trigger-icon v-if="isTrigger" :class="$style.triggerIcon" /> <font-awesome-icon icon="bolt" v-if="isTrigger" size="xs" :class="$style.triggerIcon" />
<n8n-tooltip <n8n-tooltip
v-if="!!$slots.tooltip" v-if="!!$slots.tooltip"
placement="top" placement="top"
@ -36,7 +36,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import TriggerIcon from './TriggerIcon.vue';
import N8nTooltip from '../N8nTooltip'; import N8nTooltip from '../N8nTooltip';
export interface Props { export interface Props {
@ -60,14 +59,14 @@ defineEmits<{
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
z-index: 1; z-index: 1;
padding: 11px 8px 11px 0; padding: var(--spacing-xs) var(--spacing-2xs) var(--spacing-xs) 0;
&.hasAction { &.hasAction {
user-select: none; user-select: none;
} }
} }
.creatorNode:hover .panelIcon { .creatorNode:hover .panelIcon {
color: var(--color-text-light); color: var(--action-arrow-color-hover, var(--color-text-light));
} }
.panelIcon { .panelIcon {
@ -76,7 +75,7 @@ defineEmits<{
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
margin-left: var(--spacing-2xs); margin-left: var(--spacing-2xs);
color: var(--color-text-lighter); color: var(--action-arrow-color, var(--color-text-lighter));
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
border: none; border: none;
@ -110,11 +109,12 @@ defineEmits<{
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
line-height: 1rem; line-height: 1rem;
font-weight: 400; font-weight: 400;
color: var(--node-creator-description-colo, var(--color-text-base)); color: var(--node-creator-description-colos, var(--color-text-base));
} }
.triggerIcon { .triggerIcon {
margin-left: var(--spacing-2xs); margin-left: var(--spacing-3xs);
color: var(--color-primary);
} }
</style> </style>

View file

@ -1,65 +0,0 @@
<template>
<span :class="$style.trigger">
<svg
width="36px"
height="36px"
viewBox="0 0 36 36"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<title>Trigger node</title>
<g
id="/integrations-(V1-feature)"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<g
id="Individual-node-view"
transform="translate(-304.000000, -137.000000)"
fill-rule="nonzero"
>
<g id="left-column" transform="translate(120.000000, 131.000000)">
<g id="trigger-badge" transform="translate(178.000000, 0.000000)">
<g id="trigger-icon" transform="translate(6.857143, 6.857143)">
<g id="Icon" transform="translate(8.571429, 0.000000)" fill="#FF6150">
<polygon
id="Icon-Path"
points="7.14285714 21.4285714 0 21.4285714 10 1.42857143 10 12.8571429 17.1428571 12.8571429 7.14285714 32.8571429"
></polygon>
</g>
<rect id="ViewBox" x="0" y="0" width="34.2857143" height="34.2857143"></rect>
</g>
</g>
</g>
</g>
</g>
</svg>
</span>
</template>
<script lang="ts">
export default {
name: 'TriggerIcon',
};
</script>
<style lang="scss" module>
.trigger {
background-color: var(--trigger-icon-background-color, var(--color-background-xlight));
border: 1px solid var(--trigger-icon-border-color, var(--color-background-xlight));
border-radius: 4px;
height: 16px;
width: 16px;
display: inline-block;
vertical-align: middle;
line-height: 16px;
> svg {
width: 100%;
height: 100%;
}
}
</style>

View file

@ -12,7 +12,7 @@
<!-- ElementUI tooltip is prone to memory-leaking so we only render it if we really need it --> <!-- ElementUI tooltip is prone to memory-leaking so we only render it if we really need it -->
<n8n-tooltip placement="top" :disabled="!showTooltip" v-if="showTooltip"> <n8n-tooltip placement="top" :disabled="!showTooltip" v-if="showTooltip">
<template #content>{{ nodeTypeName }}</template> <template #content>{{ nodeTypeName }}</template>
<div v-if="type !== 'unknown'" :class="$style['icon']"> <div v-if="type !== 'unknown'" :class="$style.icon">
<img v-if="type === 'file'" :src="src" :class="$style.nodeIconImage" /> <img v-if="type === 'file'" :src="src" :class="$style.nodeIconImage" />
<font-awesome-icon v-else :icon="name" :style="fontStyleData" /> <font-awesome-icon v-else :icon="name" :style="fontStyleData" />
</div> </div>
@ -103,11 +103,11 @@ export default Vue.extend({
<style lang="scss" module> <style lang="scss" module>
.nodeIconWrapper { .nodeIconWrapper {
width: 26px; width: var(--node-icon-size, 26px);
height: 26px; height: var(--node-icon-size, 26px);
border-radius: var(--border-radius-small); border-radius: var(--border-radius-small);
color: #444; color: var(--node-icon-color, #444);
line-height: 26px; line-height: var(--node-icon-size, 26px);
font-size: 1.1em; font-size: 1.1em;
overflow: hidden; overflow: hidden;
text-align: center; text-align: center;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View file

@ -31,8 +31,8 @@ import {
IExecutionsSummary, IExecutionsSummary,
IAbstractEventMessage, IAbstractEventMessage,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { FAKE_DOOR_FEATURES } from './constants';
import { SignInType } from './constants'; import { SignInType } from './constants';
import { FAKE_DOOR_FEATURES, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER } from './constants';
import { BulkCommand, Undoable } from '@/models/history'; import { BulkCommand, Undoable } from '@/models/history';
export * from 'n8n-design-system/types'; export * from 'n8n-design-system/types';
@ -758,13 +758,15 @@ export interface ISubcategoryItemProps {
subcategory: string; subcategory: string;
description: string; description: string;
key?: string; key?: string;
iconType: string;
icon?: string; icon?: string;
defaults?: INodeParameters; defaults?: INodeParameters;
iconData?: { }
type: string; export interface ViewItemProps {
icon?: string; withTopBorder: boolean;
fileBuffer?: string; title: string;
}; description: string;
icon: string;
} }
export interface INodeItemProps { export interface INodeItemProps {
@ -779,10 +781,11 @@ export interface IActionItemProps {
export interface ICategoryItemProps { export interface ICategoryItemProps {
expanded: boolean; expanded: boolean;
category: string;
name: string;
} }
export interface CreateElementBase { export interface CreateElementBase {
category: string;
key: string; key: string;
includedByTrigger?: boolean; includedByTrigger?: boolean;
includedByRegular?: boolean; includedByRegular?: boolean;
@ -790,6 +793,7 @@ export interface CreateElementBase {
export interface NodeCreateElement extends CreateElementBase { export interface NodeCreateElement extends CreateElementBase {
type: 'node'; type: 'node';
category?: string[];
properties: INodeItemProps; properties: INodeItemProps;
} }
@ -802,9 +806,14 @@ export interface SubcategoryCreateElement extends CreateElementBase {
type: 'subcategory'; type: 'subcategory';
properties: ISubcategoryItemProps; properties: ISubcategoryItemProps;
} }
export interface ViewCreateElement extends CreateElementBase {
type: 'view';
properties: ViewItemProps;
}
export interface ActionCreateElement extends CreateElementBase { export interface ActionCreateElement extends CreateElementBase {
type: 'action'; type: 'action';
category: string;
properties: IActionItemProps; properties: IActionItemProps;
} }
@ -812,6 +821,7 @@ export type INodeCreateElement =
| NodeCreateElement | NodeCreateElement
| CategoryCreateElement | CategoryCreateElement
| SubcategoryCreateElement | SubcategoryCreateElement
| ViewCreateElement
| ActionCreateElement; | ActionCreateElement;
export interface ICategoriesWithNodes { export interface ICategoriesWithNodes {
@ -1110,13 +1120,13 @@ export type IFakeDoorLocation =
| 'credentialsModal' | 'credentialsModal'
| 'workflowShareModal'; | 'workflowShareModal';
export type INodeFilterType = 'Regular' | 'Trigger' | 'All'; export type INodeFilterType = typeof REGULAR_NODE_FILTER | typeof TRIGGER_NODE_FILTER;
export interface INodeCreatorState { export interface INodeCreatorState {
itemsFilter: string; itemsFilter: string;
showTabs: boolean;
showScrim: boolean; showScrim: boolean;
selectedType: INodeFilterType; rootViewHistory: INodeFilterType[];
selectedView: INodeFilterType;
} }
export interface ISettingsState { export interface ISettingsState {

View file

@ -1,6 +1,6 @@
<template> <template>
<div <div
class="node-wrapper" :class="{ 'node-wrapper': true, 'node-wrapper--trigger': isTriggerNode }"
:style="nodePosition" :style="nodePosition"
:id="nodeId" :id="nodeId"
data-test-id="canvas-node" data-test-id="canvas-node"
@ -22,6 +22,9 @@
v-touch:start="touchStart" v-touch:start="touchStart"
v-touch:end="touchEnd" v-touch:end="touchEnd"
> >
<i class="trigger-icon">
<font-awesome-icon icon="bolt" size="lg" v-if="isTriggerNode" />
</i>
<div <div
v-if="!data.disabled" v-if="!data.disabled"
:class="{ 'node-info-icon': true, 'shift-icon': shiftOutputCount }" :class="{ 'node-info-icon': true, 'shift-icon': shiftOutputCount }"
@ -196,6 +199,7 @@ import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv'; import { useNDVStore } from '@/stores/ndv';
import { useNodeTypesStore } from '@/stores/nodeTypes'; import { useNodeTypesStore } from '@/stores/nodeTypes';
import { EnableNodeToggleCommand } from '@/models/history'; import { EnableNodeToggleCommand } from '@/models/history';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
export default mixins( export default mixins(
externalHooks, externalHooks,
@ -208,6 +212,7 @@ export default mixins(
name: 'Node', name: 'Node',
components: { components: {
TitledList, TitledList,
FontAwesomeIcon,
NodeIcon, NodeIcon,
}, },
props: { props: {
@ -673,7 +678,6 @@ export default mixins(
border: 2px solid var(--color-foreground-xdark); border: 2px solid var(--color-foreground-xdark);
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);
&.executing { &.executing {
background-color: var(--color-primary-tint-3) !important; background-color: var(--color-primary-tint-3) !important;
@ -793,6 +797,22 @@ export default mixins(
} }
} }
} }
&--trigger .node-default .node-box {
border-radius: 32px 8px 8px 32px;
}
.trigger-icon {
position: absolute;
right: 100%;
top: 0;
bottom: 0;
margin: auto;
color: var(--color-primary);
align-items: center;
justify-content: center;
height: fit-content;
// Increase click radius of the bolt icon
padding: var(--spacing-2xs);
}
} }
.select-background { .select-background {
@ -810,6 +830,10 @@ export default mixins(
top: -8px !important; top: -8px !important;
height: 116px; height: 116px;
width: 116px !important; width: 116px !important;
.node-wrapper--trigger & {
border-radius: 36px 8px 8px 36px;
}
} }
.disabled-linethrough { .disabled-linethrough {

View file

@ -5,111 +5,78 @@
tabindex="0" tabindex="0"
data-test-id="categorized-items" data-test-id="categorized-items"
:class="$style.categorizedItems" :class="$style.categorizedItems"
:key="`${activeSubcategoryTitle}_transition`" :key="`${activeSubcategoryTitle + selectedViewType}_transition`"
@keydown.capture="nodeFilterKeyDown" @keydown.capture="nodeFilterKeyDown"
> >
<div v-if="$slots.header">
<slot name="header" />
</div>
<div <div
:class="$style.subcategoryHeader" :class="{
v-if="activeSubcategory" [$style.header]: true,
[$style.headerWithBackground]: activeSubcategory,
}"
data-test-id="categorized-items-subcategory" data-test-id="categorized-items-subcategory"
> >
<button :class="$style.subcategoryBackButton" @click="onSubcategoryClose"> <button
:class="$style.backButton"
@click="onBackButton"
v-if="isViewNavigated || activeSubcategory"
>
<font-awesome-icon :class="$style.subcategoryBackIcon" icon="arrow-left" size="2x" /> <font-awesome-icon :class="$style.subcategoryBackIcon" icon="arrow-left" size="2x" />
</button> </button>
<node-icon <div v-if="isRootView && $slots.header">
v-if="showSubcategoryIcon && activeSubcategory.properties.nodeType" <slot name="header" />
:class="$style.nodeIcon" </div>
:nodeType="activeSubcategory.properties.nodeType" <template v-if="activeSubcategory">
:size="16" <n8n-node-icon
:shrink="false" :class="$style.nodeIcon"
/> v-if="showSubcategoryIcon && activeSubcategory.properties.icon"
<span v-text="activeSubcategoryTitle" /> :type="activeSubcategory.properties.iconType || 'unknown'"
:src="activeSubcategory.properties.icon"
:name="activeSubcategory.properties.icon"
:color="activeSubcategory.properties.color"
:circle="false"
:showTooltip="false"
:size="16"
/>
<span v-text="activeSubcategoryTitle" />
</template>
</div>
<div
v-if="isRootView && $slots.description"
:class="{
[$style.description]: true,
[$style.descriptionOffset]: isViewNavigated || activeSubcategory,
}"
>
<slot name="description" />
</div> </div>
<search-bar <search-bar
v-if="alwaysShowSearch || isSearchVisible" v-if="alwaysShowSearch || isSearchVisible"
:key="nodeCreatorStore.selectedType" :key="nodeCreatorStore.selectedView"
:value="nodeCreatorStore.itemsFilter" :value="searchFilter"
:placeholder=" :placeholder="
searchPlaceholder searchPlaceholder
? searchPlaceholder ? searchPlaceholder
: $locale.baseText('nodeCreator.searchBar.searchNodes') : $locale.baseText('nodeCreator.searchBar.searchNodes')
" "
ref="searchBar"
@input="onNodeFilterChange" @input="onNodeFilterChange"
/> />
<template v-if="searchFilter.length > 0 && filteredNodeTypes.length === 0"> <div :class="$style.scrollable" ref="scrollableContainer">
<no-results
data-test-id="categorized-no-results"
:showRequest="
!$slots.noResultsTitle && !$slots.noResultsAction && filteredAllNodeTypes.length === 0
"
:show-icon="
!$slots.noResultsTitle && !$slots.noResultsAction && filteredAllNodeTypes.length === 0
"
>
<template v-if="$slots.noResultsTitle" #title>
<slot name="noResultsTitle" />
</template>
<!-- There are results in other sub-categories/tabs -->
<template v-else-if="filteredAllNodeTypes.length > 0" #title>
<p v-html="$locale.baseText('nodeCreator.noResults.clickToSeeResults')" />
</template>
<template v-else #title>
<p v-text="$locale.baseText('nodeCreator.noResults.weDidntMakeThatYet')" />
</template>
<template v-if="$slots.noResultsAction" #action>
<slot name="noResultsAction" />
</template>
<template v-else-if="filteredAllNodeTypes.length === 0" #action>
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
<n8n-link
@click="selectHttpRequest"
v-if="[REGULAR_NODE_FILTER, ALL_NODE_FILTER].includes(nodeCreatorStore.selectedType)"
>
{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}
</n8n-link>
<template v-if="nodeCreatorStore.selectedType === ALL_NODE_FILTER">
{{ $locale.baseText('nodeCreator.noResults.or') }}
</template>
<n8n-link
@click="selectWebhook"
v-if="[TRIGGER_NODE_FILTER, ALL_NODE_FILTER].includes(nodeCreatorStore.selectedType)"
>
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
</n8n-link>
{{ $locale.baseText('nodeCreator.noResults.node') }}
</template>
<n8n-link
@click="selectWebhook"
v-if="[TRIGGER_NODE_FILTER, ALL_NODE_FILTER].includes(nodeCreatorStore.selectedType)"
>
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
{{ $locale.baseText('nodeCreator.noResults.node') }}
</n8n-link>
</no-results>
</template>
<div :class="$style.scrollable" ref="scrollableContainer" v-else>
<item-iterator <item-iterator
:elements="searchFilter.length === 0 ? renderedItems : filteredNodeTypes" :elements="searchFilter.length === 0 ? renderedItems : mergedFilteredNodes"
:activeIndex="activeSubcategory ? activeSubcategoryIndex : activeIndex" :activeIndex="activeSubcategory ? activeSubcategoryIndex : activeIndex"
:with-actions-getter="withActionsGetter" :with-actions-getter="withActionsGetter"
:lazyRender="lazyRender" :with-description-getter="withDescriptionGetter"
:enable-global-categories-counter="enableGlobalCategoriesCounter" :lazyRender="true"
@selected="selected" @selected="selected"
@actionsOpen="$listeners.actionsOpen" @actionsOpen="$listeners.actionsOpen"
@nodeTypeSelected="$listeners.nodeTypeSelected" @nodeTypeSelected="$listeners.nodeTypeSelected"
> />
</item-iterator> <div v-if="searchFilter.length > 0 && mergedFilteredNodes.length === 0">
<div :class="$style.footer" v-if="$slots.footer"> <slot name="noResults" />
</div>
<div :class="$style.footer" v-else-if="$slots.footer">
<slot name="footer" /> <slot name="footer" />
</div> </div>
</div> </div>
@ -121,7 +88,6 @@
import { import {
computed, computed,
reactive, reactive,
onMounted,
watch, watch,
getCurrentInstance, getCurrentInstance,
toRefs, toRefs,
@ -130,30 +96,19 @@ import {
nextTick, nextTick,
} from 'vue'; } from 'vue';
import camelcase from 'lodash.camelcase'; import camelcase from 'lodash.camelcase';
import { externalHooks } from '@/mixins/externalHooks'; import { externalHooks } from '@/mixins/externalHooks';
import useGlobalLinkActions from '@/composables/useGlobalLinkActions';
import { INodeTypeDescription } from 'n8n-workflow'; import { INodeTypeDescription } from 'n8n-workflow';
import ItemIterator from './ItemIterator.vue'; import ItemIterator from './ItemIterator.vue';
import NoResults from './NoResults.vue';
import SearchBar from './SearchBar.vue'; import SearchBar from './SearchBar.vue';
import NodeIcon from '@/components/NodeIcon.vue';
import { import {
INodeCreateElement, INodeCreateElement,
ISubcategoryItemProps, ISubcategoryItemProps,
ICategoryItemProps, ICategoryItemProps,
ICategoriesWithNodes,
SubcategoryCreateElement, SubcategoryCreateElement,
NodeCreateElement, NodeCreateElement,
CategoryCreateElement,
INodeItemProps,
} from '@/Interface'; } from '@/Interface';
import {
WEBHOOK_NODE_TYPE,
HTTP_REQUEST_NODE_TYPE,
ALL_NODE_FILTER,
TRIGGER_NODE_FILTER,
REGULAR_NODE_FILTER,
NODE_TYPE_COUNT_MAPPER,
} from '@/constants';
import { BaseTextKey } from '@/plugins/i18n'; import { BaseTextKey } from '@/plugins/i18n';
import { sublimeSearch, matchesNodeType, matchesSelectType } from '@/utils'; import { sublimeSearch, matchesNodeType, matchesSelectType } from '@/utils';
import { useWorkflowsStore } from '@/stores/workflows'; import { useWorkflowsStore } from '@/stores/workflows';
@ -161,45 +116,38 @@ import { useRootStore } from '@/stores/n8nRootStore';
import { useNodeCreatorStore } from '@/stores/nodeCreator'; import { useNodeCreatorStore } from '@/stores/nodeCreator';
export interface Props { export interface Props {
flatten?: boolean;
filterByType?: boolean;
showSubcategoryIcon?: boolean; showSubcategoryIcon?: boolean;
alwaysShowSearch?: boolean; alwaysShowSearch?: boolean;
expandAllCategories?: boolean; hideOtherCategoryItems?: boolean;
enableGlobalCategoriesCounter?: boolean;
lazyRender?: boolean; lazyRender?: boolean;
searchPlaceholder?: string; searchPlaceholder?: string;
withActionsGetter?: (element: NodeCreateElement) => boolean; withActionsGetter?: (element: NodeCreateElement) => boolean;
withDescriptionGetter?: (element: NodeCreateElement) => boolean;
searchItems?: INodeCreateElement[]; searchItems?: INodeCreateElement[];
excludedSubcategories?: string[];
firstLevelItems?: INodeCreateElement[]; firstLevelItems?: INodeCreateElement[];
initialActiveCategories?: string[];
initialActiveIndex?: number;
categorizedItems: INodeCreateElement[]; categorizedItems: INodeCreateElement[];
allItems: INodeCreateElement[];
categoriesWithNodes: ICategoriesWithNodes;
subcategoryOverride?: SubcategoryCreateElement | undefined; subcategoryOverride?: SubcategoryCreateElement | undefined;
allItems?: INodeCreateElement[];
} }
const OTHER_RESULT_CATEGORY = 'searchAll';
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
filterByType: true, allItems: () => [],
searchItems: () => [], searchItems: () => [],
excludedSubcategories: () => [],
firstLevelItems: () => [], firstLevelItems: () => [],
initialActiveCategories: () => [],
}); });
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'subcategoryClose', value: INodeCreateElement[]): void; (event: 'subcategoryClose', value: INodeCreateElement[]): void;
(event: 'onSubcategorySelected', value: INodeCreateElement): void; (event: 'onSubcategorySelected', value: INodeCreateElement): void;
(event: 'nodeTypeSelected', value: string[]): void; (event: 'nodeTypeSelected', value: string[]): void;
(event: 'actionSelected', value: INodeCreateElement): void; (event: 'actionSelected', value: INodeCreateElement): void;
(event: 'actionsOpen', value: INodeTypeDescription): void; (event: 'actionsOpen', value: INodeTypeDescription): void;
}>(); }>();
const instance = getCurrentInstance(); const instance = getCurrentInstance();
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
const { $externalHooks } = new externalHooks(); const { $externalHooks } = new externalHooks();
const { defaultLocale } = useRootStore(); const { defaultLocale } = useRootStore();
@ -207,7 +155,7 @@ const { workflowId } = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore(); const nodeCreatorStore = useNodeCreatorStore();
const state = reactive({ const state = reactive({
activeCategory: props.initialActiveCategories, activeCategories: [] as string[],
// Keep track of activated subcategories so we could traverse back more than one level // Keep track of activated subcategories so we could traverse back more than one level
activeSubcategoryHistory: [] as Array<{ activeSubcategoryHistory: [] as Array<{
scrollPosition: number; scrollPosition: number;
@ -215,22 +163,23 @@ const state = reactive({
activeIndex: number; activeIndex: number;
filter: string; filter: string;
}>, }>,
activeIndex: props.initialActiveIndex || 0, activeIndex: 0,
activeSubcategoryIndex: 0, activeSubcategoryIndex: 0,
ALL_NODE_FILTER,
TRIGGER_NODE_FILTER,
REGULAR_NODE_FILTER,
mainPanelContainer: null as HTMLElement | null, mainPanelContainer: null as HTMLElement | null,
transitionDirection: 'in', transitionDirection: 'in',
}); });
const searchBar = ref<InstanceType<typeof SearchBar>>(); const searchBar = ref<InstanceType<typeof SearchBar>>();
const scrollableContainer = ref<InstanceType<typeof HTMLElement>>(); const scrollableContainer = ref<InstanceType<typeof HTMLElement>>();
const activeSubcategory = computed<INodeCreateElement | null>( const activeSubcategory = computed<INodeCreateElement | null>(() => {
() => return (
state.activeSubcategoryHistory[state.activeSubcategoryHistory.length - 1]?.subcategory || null, state.activeSubcategoryHistory[state.activeSubcategoryHistory.length - 1]?.subcategory || null
); );
});
const categoriesKeys = computed(() =>
props.categorizedItems.filter((item) => item.type === 'category').map((item) => item.key),
);
const activeSubcategoryTitle = computed<string>(() => { const activeSubcategoryTitle = computed<string>(() => {
if (!activeSubcategory.value || !activeSubcategory.value.properties) return ''; if (!activeSubcategory.value || !activeSubcategory.value.properties) return '';
@ -252,12 +201,7 @@ const activeSubcategoryTitle = computed<string>(() => {
const searchFilter = computed<string>(() => nodeCreatorStore.itemsFilter.toLowerCase().trim()); const searchFilter = computed<string>(() => nodeCreatorStore.itemsFilter.toLowerCase().trim());
const matchedTypeNodes = computed<INodeCreateElement[]>(() => { const selectedViewType = computed(() => nodeCreatorStore.selectedView);
if (!props.filterByType) return props.searchItems;
return props.searchItems.filter((el: INodeCreateElement) =>
matchesSelectType(el, nodeCreatorStore.selectedType),
);
});
const filteredNodeTypes = computed<INodeCreateElement[]>(() => { const filteredNodeTypes = computed<INodeCreateElement[]>(() => {
const filter = searchFilter.value; const filter = searchFilter.value;
@ -267,121 +211,151 @@ const filteredNodeTypes = computed<INodeCreateElement[]>(() => {
returnItems = props.searchItems.filter((el: INodeCreateElement) => { returnItems = props.searchItems.filter((el: INodeCreateElement) => {
return ( return (
filter && filter &&
matchesSelectType(el, nodeCreatorStore.selectedType) && matchesSelectType(el, nodeCreatorStore.selectedView) &&
matchesNodeType(el, filter) matchesNodeType(el, filter)
); );
}); });
} else { } else {
const matchingNodes = props.filterByType const matchingNodes =
? props.searchItems.filter((el) => matchesSelectType(el, nodeCreatorStore.selectedType)) subcategorizedItems.value.length > 0 ? subcategorizedItems.value : props.searchItems;
: props.searchItems;
const matchedCategorizedNodes = sublimeSearch<INodeCreateElement>(filter, matchingNodes, [ returnItems = getFilteredNodes(matchingNodes);
{ key: 'properties.nodeType.displayName', weight: 2 },
{ key: 'properties.nodeType.codex.alias', weight: 1 },
]);
returnItems = matchedCategorizedNodes.map(({ item }) => item);
} }
return returnItems; return returnItems;
}); });
const filteredAllNodeTypes = computed<INodeCreateElement[]>(() => { const isViewNavigated = computed(() => nodeCreatorStore.rootViewHistory.length > 1);
if (filteredNodeTypes.value.length > 0) return [];
const matchedAllNodex = props.allItems.filter((el: INodeCreateElement) => { const globalFilteredNodeTypes = computed<INodeCreateElement[]>(() => {
return searchFilter.value && el.type === 'node' && matchesNodeType(el, searchFilter.value); const result = getFilteredNodes(props.allItems).reduce((acc, item) => {
}); if (acc.find((el) => trimTriggerNodeName(el.key) === trimTriggerNodeName(item.key))) {
return acc;
}
return matchedAllNodex; return [...acc, item];
}, [] as INodeCreateElement[]);
return result;
}); });
const categorized = computed<INodeCreateElement[]>(() => { const otherCategoryNodes = computed(() => {
return props.categorizedItems.reduce((accu: INodeCreateElement[], el: INodeCreateElement) => { const nodes = [];
if (
el.type === 'subcategory' &&
(props.excludedSubcategories || []).includes(
(el.properties as ISubcategoryItemProps).subcategory,
)
) {
return accu;
}
if (el.type !== 'category' && !state.activeCategory.includes(el.category)) { // Get diff of nodes between `globalFilteredNodeTypes` and `filteredNodeTypes`
return accu; for (const node of globalFilteredNodeTypes.value) {
} const isNodeInFiltered = filteredNodeTypes.value.find(
(el) => trimTriggerNodeName(el.key) === trimTriggerNodeName(node.key),
);
if (!matchesSelectType(el, nodeCreatorStore.selectedType)) { if (!isNodeInFiltered) nodes.push(node);
return accu; }
}
if (el.type === 'category') { return nodes;
accu.push({
...el,
properties: {
expanded: state.activeCategory.includes(el.category),
},
} as INodeCreateElement);
return accu;
}
accu.push(el);
return accu;
}, []);
}); });
const mergedFilteredNodes = computed<INodeCreateElement[]>(() => {
if (props.hideOtherCategoryItems) return filteredNodeTypes.value;
const isExpanded = state.activeCategories.includes(OTHER_RESULT_CATEGORY);
const searchCategory: CategoryCreateElement = {
type: 'category',
key: OTHER_RESULT_CATEGORY,
properties: {
category: OTHER_RESULT_CATEGORY,
name: `Results in other categories (${otherCategoryNodes.value.length})`,
expanded: isExpanded,
},
};
const nodeTypes = [...filteredNodeTypes.value];
if (otherCategoryNodes.value.length > 0) {
nodeTypes.push(searchCategory);
}
if (isExpanded) {
nodeTypes.push(...otherCategoryNodes.value);
}
return nodeTypes;
});
const isRootView = computed(() => activeSubcategory.value === null);
const subcategorizedItems = computed<INodeCreateElement[]>(() => { const subcategorizedItems = computed<INodeCreateElement[]>(() => {
if (!activeSubcategory.value) return []; if (!activeSubcategory.value) return [];
const category = activeSubcategory.value.category; const items = props.searchItems.filter((el: INodeCreateElement) => {
const subcategory = (activeSubcategory.value.properties as ISubcategoryItemProps).subcategory; if (!activeSubcategory.value) return false;
// If no category is set, we use all categorized nodes const subcategories = Object.values(
const nodes = category (el.properties as INodeItemProps).nodeType.codex?.subcategories || {},
? props.categoriesWithNodes[category][subcategory].nodes ).flat();
: categorized.value; return subcategories.includes(activeSubcategory.value.key);
});
return nodes.filter((el: INodeCreateElement) => return items.filter((el: INodeCreateElement) =>
matchesSelectType(el, nodeCreatorStore.selectedType), matchesSelectType(el, nodeCreatorStore.selectedView),
); );
}); });
const filteredCategorizedItems = computed<INodeCreateElement[]>(() => {
let categoriesCount = 0;
const reducedItems = props.categorizedItems.reduce(
(acc: INodeCreateElement[], el: INodeCreateElement) => {
if (el.type === 'category') {
el.properties.expanded = state.activeCategories.includes(el.key);
categoriesCount++;
return [...acc, el];
}
if (el.type === 'action' && state.activeCategories.includes(el.category)) {
return [...acc, el];
}
return acc;
},
[],
);
// If there is only one category we don't show it
if (categoriesCount <= 1)
return reducedItems.filter((el: INodeCreateElement) => el.type !== 'category');
return reducedItems;
});
const renderedItems = computed<INodeCreateElement[]>(() => { const renderedItems = computed<INodeCreateElement[]>(() => {
if (props.firstLevelItems.length > 0 && activeSubcategory.value === null) if (props.firstLevelItems.length > 0 && activeSubcategory.value === null)
return props.firstLevelItems; return props.firstLevelItems;
if (props.flatten) return matchedTypeNodes.value;
if (subcategorizedItems.value.length === 0) return categorized.value;
const isSingleCategory = // If active subcategory is * then we show all items
subcategorizedItems.value.filter((item) => item.type === 'category').length === 1; if (activeSubcategory.value?.key === '*') return props.searchItems;
return isSingleCategory ? subcategorizedItems.value.slice(1) : subcategorizedItems.value; // Otherwise we show only items that match the subcategory
if (subcategorizedItems.value.length > 0) return subcategorizedItems.value;
// Finally if none of the above is true we show the categorized items
return filteredCategorizedItems.value;
}); });
const isSearchVisible = computed<boolean>(() => { const isSearchVisible = computed<boolean>(() => {
if (subcategorizedItems.value.length === 0) return true; if (subcategorizedItems.value.length === 0) return true;
let totalItems = 0; return subcategorizedItems.value.length > 9;
for (const item of subcategorizedItems.value) {
// Category contains many nodes so we need to count all of them
// for the current selectedType
if (item.type === 'category') {
const categoryItems = props.categoriesWithNodes[item.key];
const categoryItemsCount = Object.values(categoryItems)?.[0];
const countKeys = NODE_TYPE_COUNT_MAPPER[nodeCreatorStore.selectedType];
for (const countKey of countKeys) {
totalItems += categoryItemsCount[countKey as 'triggerCount' | 'regularCount'];
}
continue;
}
// If it's not category, it must be just a node item so we count it as 1
totalItems += 1;
}
return totalItems > 9;
}); });
// Methods // Methods
function trimTriggerNodeName(nodeName: string) {
return nodeName.toLowerCase().replace('trigger', '');
}
function getFilteredNodes(items: INodeCreateElement[]) {
// In order to support the old search we need to remove the 'trigger' part
const trimmedFilter = searchFilter.value.toLowerCase().replace('trigger', '');
return (
sublimeSearch<INodeCreateElement>(trimmedFilter, items, [
{ key: 'properties.nodeType.displayName', weight: 2 },
{ key: 'properties.nodeType.codex.alias', weight: 1 },
]) || []
).map(({ item }) => item);
}
function getScrollTop() { function getScrollTop() {
return scrollableContainer.value?.scrollTop || 0; return scrollableContainer.value?.scrollTop || 0;
} }
@ -390,26 +364,10 @@ function setScrollTop(scrollTop: number) {
scrollableContainer.value.scrollTop = scrollTop; scrollableContainer.value.scrollTop = scrollTop;
} }
} }
function switchToAllTabAndFilter() {
const currentFilter = nodeCreatorStore.itemsFilter;
nodeCreatorStore.setShowTabs(true);
nodeCreatorStore.setSelectedType(ALL_NODE_FILTER);
state.activeSubcategoryHistory = [];
nextTick(() => onNodeFilterChange(currentFilter));
}
function onNodeFilterChange(filter: string) { function onNodeFilterChange(filter: string) {
nodeCreatorStore.setFilter(filter); nodeCreatorStore.setFilter(filter);
} }
function selectWebhook() {
emit('nodeTypeSelected', [WEBHOOK_NODE_TYPE]);
}
function selectHttpRequest() {
emit('nodeTypeSelected', [HTTP_REQUEST_NODE_TYPE]);
}
function nodeFilterKeyDown(e: KeyboardEvent) { function nodeFilterKeyDown(e: KeyboardEvent) {
// We only want to propagate 'Escape' as it closes the node-creator and // We only want to propagate 'Escape' as it closes the node-creator and
// 'Tab' which toggles it // 'Tab' which toggles it
@ -438,7 +396,7 @@ function nodeFilterKeyDown(e: KeyboardEvent) {
) { ) {
selected(activeNodeType); selected(activeNodeType);
} else if (e.key === 'ArrowLeft') { } else if (e.key === 'ArrowLeft') {
onSubcategoryClose(); onBackButton();
} else if ( } else if (
e.key === 'ArrowRight' && e.key === 'ArrowRight' &&
activeNodeType?.type === 'category' && activeNodeType?.type === 'category' &&
@ -466,6 +424,8 @@ function nodeFilterKeyDown(e: KeyboardEvent) {
selected(activeNodeType); selected(activeNodeType);
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'subcategory') { } else if (e.key === 'ArrowRight' && activeNodeType?.type === 'subcategory') {
selected(activeNodeType); selected(activeNodeType);
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'view') {
selected(activeNodeType);
} else if ( } else if (
e.key === 'ArrowRight' && e.key === 'ArrowRight' &&
activeNodeType?.type === 'category' && activeNodeType?.type === 'category' &&
@ -478,20 +438,29 @@ function nodeFilterKeyDown(e: KeyboardEvent) {
(activeNodeType.properties as ICategoryItemProps).expanded (activeNodeType.properties as ICategoryItemProps).expanded
) { ) {
selected(activeNodeType); selected(activeNodeType);
} else if (e.key === 'ArrowLeft' && isViewNavigated.value) {
onBackButton();
} else if (e.key === 'ArrowRight' && ['node', 'action'].includes(activeNodeType?.type)) { } else if (e.key === 'ArrowRight' && ['node', 'action'].includes(activeNodeType?.type)) {
selected(activeNodeType); selected(activeNodeType);
} }
} }
function selected(element: INodeCreateElement) { function selected(element: INodeCreateElement) {
const typeHandler = { const typeHandler = {
category: () => onCategorySelected(element.category), category: () => onCategorySelected(element),
subcategory: () => onSubcategorySelected(element), subcategory: () => onSubcategorySelected(element),
node: () => onNodeSelected(element as NodeCreateElement), node: () => onNodeSelected(element as NodeCreateElement),
action: () => onActionSelected(element), action: () => onActionSelected(element),
view: () => onViewSelected(element),
}; };
typeHandler[element.type](); typeHandler[element.type]();
} }
function onViewSelected(view: Record<string, any>) {
state.transitionDirection = 'in';
state.activeIndex = 0;
nodeCreatorStore.setSelectedView(view.key);
nodeCreatorStore.setFilter('');
}
function onNodeSelected(element: NodeCreateElement) { function onNodeSelected(element: NodeCreateElement) {
const hasActions = (element.properties.nodeType?.actions?.length || 0) > 0; const hasActions = (element.properties.nodeType?.actions?.length || 0) > 0;
@ -502,20 +471,19 @@ function onNodeSelected(element: NodeCreateElement) {
emit('nodeTypeSelected', [element.key]); emit('nodeTypeSelected', [element.key]);
} }
function onCategorySelected(category: string) { function onCategorySelected(element: CategoryCreateElement) {
if (state.activeCategory.includes(category)) { const categoryKey = element.properties.category;
state.activeCategory = state.activeCategory.filter((active: string) => active !== category); if (state.activeCategories.includes(categoryKey)) {
state.activeCategories = state.activeCategories.filter(
(active: string) => active !== categoryKey,
);
} else { } else {
state.activeCategory = [...state.activeCategory, category]; state.activeCategories = [...state.activeCategories, categoryKey];
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', { instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', {
category_name: category, category_name: categoryKey,
workflow_id: workflowId, workflow_id: workflowId,
}); });
} }
state.activeIndex = categorized.value.findIndex(
(el: INodeCreateElement) => el.category === category,
);
} }
function onActionSelected(element: INodeCreateElement) { function onActionSelected(element: INodeCreateElement) {
emit('actionSelected', element); emit('actionSelected', element);
@ -533,7 +501,6 @@ function onSubcategorySelected(selected: INodeCreateElement, track = true) {
}); });
nodeCreatorStore.setFilter(''); nodeCreatorStore.setFilter('');
emit('onSubcategorySelected', selected); emit('onSubcategorySelected', selected);
nodeCreatorStore.setShowTabs(false);
state.activeSubcategoryIndex = 0; state.activeSubcategoryIndex = 0;
if (track) { if (track) {
@ -544,8 +511,14 @@ function onSubcategorySelected(selected: INodeCreateElement, track = true) {
} }
} }
async function onSubcategoryClose() { async function onBackButton() {
state.transitionDirection = 'out'; state.transitionDirection = 'out';
// Switching views
if (isRootView.value && isViewNavigated.value) {
nodeCreatorStore.closeCurrentView();
return;
}
const poppedSubCategory = state.activeSubcategoryHistory.pop(); const poppedSubCategory = state.activeSubcategoryHistory.pop();
onNodeFilterChange(poppedSubCategory?.filter || ''); onNodeFilterChange(poppedSubCategory?.filter || '');
await nextTick(); await nextTick();
@ -556,40 +529,30 @@ async function onSubcategoryClose() {
await nextTick(); await nextTick();
setScrollTop(poppedSubCategory?.scrollPosition || 0); setScrollTop(poppedSubCategory?.scrollPosition || 0);
state.activeSubcategoryIndex = poppedSubCategory?.activeIndex || 0; state.activeSubcategoryIndex = poppedSubCategory?.activeIndex || 0;
if (!nodeCreatorStore.showScrim && state.activeSubcategoryHistory.length === 0) {
nodeCreatorStore.setShowTabs(true);
}
} }
watch(
() => props.expandAllCategories,
(expandAll) => {
if (expandAll) state.activeCategory = Object.keys(props.categoriesWithNodes);
},
);
watch( watch(
() => props.subcategoryOverride, () => props.subcategoryOverride,
(subcategory) => { (subcategory) => {
if (subcategory) onSubcategorySelected(subcategory, false); if (subcategory) onSubcategorySelected(subcategory, false);
}, },
); );
watch(
onMounted(() => { () => props.categorizedItems,
registerCustomAction('showAllNodeCreatorNodes', switchToAllTabAndFilter); () => {
}); state.activeCategories = [...categoriesKeys.value, OTHER_RESULT_CATEGORY];
},
);
onUnmounted(() => { onUnmounted(() => {
nodeCreatorStore.setFilter(''); nodeCreatorStore.setFilter('');
unregisterCustomAction('showAllNodeCreatorNodes');
}); });
watch(filteredNodeTypes, (returnItems) => { watch(filteredNodeTypes, (returnItems) => {
$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', { $externalHooks().run('nodeCreateList.filteredNodeTypesComputed', {
nodeFilter: nodeCreatorStore.itemsFilter, nodeFilter: nodeCreatorStore.itemsFilter,
result: returnItems, result: returnItems,
selectedType: nodeCreatorStore.selectedType, selectedType: nodeCreatorStore.selectedView,
}); });
}); });
@ -609,13 +572,13 @@ watch(
$externalHooks().run('nodeCreateList.nodeFilterChanged', { $externalHooks().run('nodeCreateList.nodeFilterChanged', {
oldValue, oldValue,
newValue, newValue,
selectedType: nodeCreatorStore.selectedType, selectedType: nodeCreatorStore.selectedView,
filteredNodes: filteredNodeTypes.value, filteredNodes: filteredNodeTypes.value,
}); });
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.nodeFilterChanged', { instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.nodeFilterChanged', {
oldValue, oldValue,
newValue, newValue,
selectedType: nodeCreatorStore.selectedType, selectedType: nodeCreatorStore.selectedView,
filteredNodes: filteredNodeTypes.value, filteredNodes: filteredNodeTypes.value,
workflow_id: workflowId, workflow_id: workflowId,
}); });
@ -650,6 +613,7 @@ const { activeSubcategoryIndex, activeIndex, mainPanelContainer } = toRefs(state
z-index: 1; z-index: 1;
} }
.nodeIcon { .nodeIcon {
--node-icon-size: 16px;
margin-right: var(--spacing-s); margin-right: var(--spacing-s);
} }
.categorizedItems { .categorizedItems {
@ -675,31 +639,39 @@ const { activeSubcategoryIndex, activeIndex, mainPanelContainer } = toRefs(state
z-index: 1; z-index: 1;
margin-top: -1px; margin-top: -1px;
} }
.subcategoryHeader { .header {
border-bottom: $node-creator-border-color solid 1px;
height: 50px;
background-color: $node-creator-subcategory-panel-header-bacground-color;
font-size: var(--font-size-l); font-size: var(--font-size-l);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-compact); line-height: var(--font-line-height-compact);
display: flex; display: flex;
align-items: center; align-items: center;
padding: 11px 15px; padding: var(--spacing-s) var(--spacing-s) var(--spacing-2xs);
}
.subcategoryBackButton { &.headerWithBackground {
border-bottom: $node-creator-border-color solid 1px;
height: 50px;
background-color: $node-creator-subcategory-panel-header-bacground-color;
padding: var(--spacing-s) var(--spacing-s);
}
}
.description {
padding: 0 var(--spacing-s) var(--spacing-2xs) var(--spacing-s);
margin-top: -4px;
}
.descriptionOffset {
margin-left: calc(var(--spacing-xl) + var(--spacing-4xs));
}
.backButton {
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: var(--spacing-s) 0; padding: 0 var(--spacing-xs) 0 0;
} }
.subcategoryBackIcon { .subcategoryBackIcon {
color: $node-creator-arrow-color; color: $node-creator-arrow-color;
height: 16px; height: 16px;
margin-right: var(--spacing-s);
padding: 0; padding: 0;
} }

View file

@ -1,34 +1,18 @@
<template> <template>
<div :class="$style.category"> <div :class="$style.category">
<span :class="$style.name"> <span :class="$style.name" v-text="item.name" />
{{ renderCategoryName(item.category) }}{{ count !== undefined ? ` (${count})` : '' }} <font-awesome-icon v-if="item.expanded" icon="chevron-down" :class="$style.arrow" />
</span>
<font-awesome-icon v-if="isExpanded" icon="chevron-down" :class="$style.arrow" />
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else /> <font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, getCurrentInstance } from 'vue'; import { ICategoryItemProps } from '@/Interface';
import camelcase from 'lodash.camelcase';
import { CategoryName } from '@/plugins/i18n';
import { INodeCreateElement, ICategoryItemProps } from '@/Interface';
export interface Props { export interface Props {
item: INodeCreateElement; item: ICategoryItemProps;
count?: number;
}
const props = defineProps<Props>();
const instance = getCurrentInstance();
const isExpanded = computed<boolean>(() => (props.item.properties as ICategoryItemProps).expanded);
function renderCategoryName(categoryName: string) {
const camelCasedCategoryName = camelcase(categoryName) as CategoryName;
const key = `nodeCreator.categoryNames.${camelCasedCategoryName}` as const;
return instance?.proxy.$locale.exists(key) ? instance?.proxy.$locale.baseText(key) : categoryName;
} }
defineProps<Props>();
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -20,18 +20,15 @@
ref="iteratorItems" ref="iteratorItems"
@click="wrappedEmit('selected', item)" @click="wrappedEmit('selected', item)"
> >
<category-item <category-item v-if="item.type === 'category'" :item="item.properties" />
v-if="item.type === 'category'"
:item="item"
:count="enableGlobalCategoriesCounter ? getCategoryCount(item) : undefined"
/>
<subcategory-item v-else-if="item.type === 'subcategory'" :item="item" /> <subcategory-item v-else-if="item.type === 'subcategory'" :item="item.properties" />
<node-item <node-item
v-else-if="item.type === 'node'" v-else-if="item.type === 'node'"
:nodeType="item.properties.nodeType" :nodeType="item.properties.nodeType"
:allow-actions="withActionsGetter && withActionsGetter(item)" :allow-actions="withActionsGetter && withActionsGetter(item)"
:allow-description="withDescriptionGetter && withDescriptionGetter(item)"
@dragstart="wrappedEmit('dragstart', item, $event)" @dragstart="wrappedEmit('dragstart', item, $event)"
@dragend="wrappedEmit('dragend', item, $event)" @dragend="wrappedEmit('dragend', item, $event)"
@nodeTypeSelected="$listeners.nodeTypeSelected" @nodeTypeSelected="$listeners.nodeTypeSelected"
@ -45,6 +42,8 @@
@dragstart="wrappedEmit('dragstart', item, $event)" @dragstart="wrappedEmit('dragstart', item, $event)"
@dragend="wrappedEmit('dragend', item, $event)" @dragend="wrappedEmit('dragend', item, $event)"
/> />
<view-item v-else-if="item.type === 'view'" :view="item.properties" />
</div> </div>
<aside <aside
v-for="item in elements.length" v-for="item in elements.length"
@ -58,15 +57,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { INodeCreateElement, CategoryCreateElement, NodeCreateElement } from '@/Interface'; import { INodeCreateElement, NodeCreateElement } from '@/Interface';
import NodeItem from './NodeItem.vue'; import NodeItem from './NodeItem.vue';
import SubcategoryItem from './SubcategoryItem.vue'; import SubcategoryItem from './SubcategoryItem.vue';
import CategoryItem from './CategoryItem.vue'; import CategoryItem from './CategoryItem.vue';
import ActionItem from './ActionItem.vue'; import ActionItem from './ActionItem.vue';
import ViewItem from './ViewItem.vue';
import { reactive, toRefs, onMounted, watch, onUnmounted, ref } from 'vue'; import { reactive, toRefs, onMounted, watch, onUnmounted, ref } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { NODE_TYPE_COUNT_MAPPER } from '@/constants';
export interface Props { export interface Props {
elements: INodeCreateElement[]; elements: INodeCreateElement[];
@ -74,6 +71,7 @@ export interface Props {
disabled?: boolean; disabled?: boolean;
lazyRender?: boolean; lazyRender?: boolean;
withActionsGetter?: (element: NodeCreateElement) => boolean; withActionsGetter?: (element: NodeCreateElement) => boolean;
withDescriptionGetter?: (element: NodeCreateElement) => boolean;
enableGlobalCategoriesCounter?: boolean; enableGlobalCategoriesCounter?: boolean;
} }
@ -102,25 +100,6 @@ function wrappedEmit(
emit((event as 'selected') || 'dragstart' || 'dragend', element, $e); emit((event as 'selected') || 'dragstart' || 'dragend', element, $e);
} }
function getCategoryCount(item: CategoryCreateElement) {
const { categoriesWithNodes } = useNodeTypesStore();
const currentCategory = categoriesWithNodes[item.category];
const subcategories = Object.keys(currentCategory);
// We need to sum subcategories count for the curent nodeType view
// to get the total count of category
const count = subcategories.reduce((accu: number, subcategory: string) => {
const countKeys = NODE_TYPE_COUNT_MAPPER[useNodeCreatorStore().selectedType];
for (const countKey of countKeys) {
accu += currentCategory[subcategory][countKey as 'triggerCount' | 'regularCount'];
}
return accu;
}, 0);
return count;
}
// Lazy render large items lists to prevent the browser from freezing // Lazy render large items lists to prevent the browser from freezing
// when loading many items. // when loading many items.

View file

@ -1,100 +1,408 @@
<template> <template>
<div class="container" ref="mainPanelContainer"> <div :class="{ [$style.mainPanel]: true, [$style.isRoot]: isRoot }">
<div class="main-panel"> <CategorizedItems
<trigger-helper-panel :subcategoryOverride="nodeAppSubcategory"
v-if="nodeCreatorStore.selectedType === TRIGGER_NODE_FILTER" :alwaysShowSearch="isActionsActive"
@nodeTypeSelected="$listeners.nodeTypeSelected" :hideOtherCategoryItems="isActionsActive"
> :categorizedItems="computedCategorizedItems"
<template #header> :searchItems="searchItems"
<type-selector /> :withActionsGetter="shouldShowNodeActions"
</template> :withDescriptionGetter="shouldShowNodeDescription"
</trigger-helper-panel> :firstLevelItems="firstLevelItems"
<categorized-items :showSubcategoryIcon="isActionsActive"
v-else :allItems="transformCreateElements(mergedAppNodes)"
enable-global-categories-counter :searchPlaceholder="searchPlaceholder"
:categorizedItems="categorizedItems" @subcategoryClose="onSubcategoryClose"
:categoriesWithNodes="categoriesWithNodes" @onSubcategorySelected="onSubcategorySelected"
:searchItems="searchItems" @nodeTypeSelected="onNodeTypeSelected"
:excludedSubcategories="[OTHER_TRIGGER_NODES_SUBCATEGORY]" @actionsOpen="setActiveActionsNodeType"
:initialActiveCategories="[CORE_NODES_CATEGORY]" @actionSelected="onActionSelected"
:allItems="categorizedItems" >
@nodeTypeSelected="$listeners.nodeTypeSelected" <template #noResults>
@actionsOpen="() => {}" <no-results
> data-test-id="categorized-no-results"
<template #header> :showRequest="!isActionsActive"
<type-selector /> :show-icon="!isActionsActive"
</template> >
</categorized-items> <template #title v-if="!isActionsActive">
</div> <p v-text="$locale.baseText('nodeCreator.noResults.weDidntMakeThatYet')" />
</template>
<template v-if="isActionsActive" #action>
<p
v-if="containsAPIAction"
v-html="getCustomAPICallHintLocale('apiCallNoResult')"
class="clickable"
@click.stop="addHttpNode(true)"
/>
<p v-else v-text="$locale.baseText('nodeCreator.noResults.noMatchingActions')" />
</template>
<template v-else #action>
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
<n8n-link v-if="[REGULAR_NODE_FILTER].includes(selectedView)" @click="addHttpNode">
{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}
</n8n-link>
<n8n-link v-if="[TRIGGER_NODE_FILTER].includes(selectedView)" @click="addWebHookNode()">
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
</n8n-link>
{{ $locale.baseText('nodeCreator.noResults.node') }}
</template>
</no-results>
</template>
<template #header>
<p
v-if="isRoot && activeView && activeView.title"
v-text="activeView.title"
:class="$style.title"
/>
</template>
<template #description>
<p
v-if="isRoot && activeView && activeView.description"
v-text="activeView.description"
:class="$style.description"
/>
</template>
<template #footer v-if="activeNodeActions && containsAPIAction">
<span
v-html="getCustomAPICallHintLocale('apiCall')"
class="clickable"
@click.stop="addHttpNode(true)"
/>
</template>
</CategorizedItems>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watch, getCurrentInstance, onMounted, onUnmounted } from 'vue'; import { reactive, toRefs, getCurrentInstance, computed, onUnmounted, ref } from 'vue';
import { externalHooks } from '@/mixins/externalHooks'; import {
import TriggerHelperPanel from './TriggerHelperPanel.vue'; INodeTypeDescription,
INodeActionTypeDescription,
INodeTypeNameVersion,
} from 'n8n-workflow';
import {
INodeCreateElement,
NodeCreateElement,
IActionItemProps,
SubcategoryCreateElement,
IUpdateInformation,
} from '@/Interface';
import { import {
ALL_NODE_FILTER,
TRIGGER_NODE_FILTER,
OTHER_TRIGGER_NODES_SUBCATEGORY,
CORE_NODES_CATEGORY, CORE_NODES_CATEGORY,
WEBHOOK_NODE_TYPE,
EMAIL_IMAP_NODE_TYPE,
CUSTOM_API_CALL_NAME,
HTTP_REQUEST_NODE_TYPE,
STICKY_NODE_TYPE,
REGULAR_NODE_FILTER,
TRIGGER_NODE_FILTER,
N8N_NODE_TYPE,
} from '@/constants'; } from '@/constants';
import CategorizedItems from './CategorizedItems.vue'; import CategorizedItems from './CategorizedItems.vue';
import TypeSelector from './TypeSelector.vue';
import { INodeCreateElement } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNodeCreatorStore } from '@/stores/nodeCreator'; import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { getCategoriesWithNodes, getCategorizedList } from '@/utils';
import { externalHooks } from '@/mixins/externalHooks';
import { useNodeTypesStore } from '@/stores/nodeTypes'; import { useNodeTypesStore } from '@/stores/nodeTypes';
import { BaseTextKey } from '@/plugins/i18n';
export interface Props { import NoResults from './NoResults.vue';
searchItems?: INodeCreateElement[]; import { useRootStore } from '@/stores/n8nRootStore';
} import useMainPanelView from './useMainPanelView';
withDefaults(defineProps<Props>(), {
searchItems: () => [],
});
const instance = getCurrentInstance(); const instance = getCurrentInstance();
const { $externalHooks } = new externalHooks();
const { workflowId } = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
const { categorizedItems, categoriesWithNodes } = useNodeTypesStore();
watch( const emit = defineEmits({
() => nodeCreatorStore.selectedType, nodeTypeSelected: (nodeTypes: string[]) => true,
(newValue, oldValue) => { });
$externalHooks().run('nodeCreateList.selectedTypeChanged', {
oldValue, const state = reactive({
newValue, isRoot: true,
}); selectedSubcategory: '',
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.selectedTypeChanged', { activeNodeActions: null as INodeTypeDescription | null,
old_filter: oldValue, });
new_filter: newValue, const { baseUrl } = useRootStore();
workflow_id: workflowId, const { $externalHooks } = new externalHooks();
}); const {
}, mergedAppNodes,
getActionData,
getNodeTypesWithManualTrigger,
setAddedNodeActionParameters,
} = useNodeCreatorStore();
const { activeView } = useMainPanelView();
const telemetry = instance?.proxy.$telemetry;
const { isTriggerNode } = useNodeTypesStore();
const containsAPIAction = computed(
() =>
state.activeNodeActions?.properties.some((p) =>
p.options?.find((o) => o.name === CUSTOM_API_CALL_NAME),
) === true,
); );
onMounted(() => { const selectedView = computed(() => useNodeCreatorStore().selectedView);
$externalHooks().run('nodeCreateList.mounted'); const computedCategorizedItems = computed(() => {
// Make sure tabs are visible on mount if (isActionsActive.value) {
nodeCreatorStore.setShowTabs(true); return sortActions(getCategorizedList(computedCategoriesWithNodes.value, true));
}
return getCategorizedList(computedCategoriesWithNodes.value, true);
}); });
const nodeAppSubcategory = computed<SubcategoryCreateElement | undefined>(() => {
if (!state.activeNodeActions) return undefined;
const icon = state.activeNodeActions.iconUrl
? `${baseUrl}${state.activeNodeActions.iconUrl}`
: state.activeNodeActions.icon?.split(':')[1];
return {
type: 'subcategory',
key: state.activeNodeActions.name,
properties: {
subcategory: state.activeNodeActions.displayName,
description: '',
iconType: state.activeNodeActions.iconUrl ? 'file' : 'icon',
icon,
color: state.activeNodeActions.defaults.color,
},
};
});
const searchPlaceholder = computed(() => {
const nodeNameTitle = state.activeNodeActions?.displayName?.trim() as string;
const actionsSearchPlaceholder = instance?.proxy.$locale.baseText(
'nodeCreator.actionsCategory.searchActions',
{ interpolate: { nodeNameTitle } },
);
return isActionsActive.value ? actionsSearchPlaceholder : undefined;
});
const filteredMergedAppNodes = computed(() => {
const WHITELISTED_APP_CORE_NODES = [EMAIL_IMAP_NODE_TYPE, WEBHOOK_NODE_TYPE];
if (isAppEventSubcategory.value)
return mergedAppNodes.filter((node) => {
const isTrigger = isTriggerNode(node.name);
const isRegularNode = !isTrigger;
const isStickyNode = node.name === STICKY_NODE_TYPE;
const isCoreNode =
node.codex?.categories?.includes(CORE_NODES_CATEGORY) &&
!WHITELISTED_APP_CORE_NODES.includes(node.name);
const hasActions = (node.actions || []).length > 0;
// Never show core nodes and sticky node in the Apps subcategory
if (isCoreNode || isStickyNode) return false;
// Only show nodes without action within their view
if (!hasActions) {
return isRegularNode
? selectedView.value === REGULAR_NODE_FILTER
: selectedView.value === TRIGGER_NODE_FILTER;
}
return true;
});
return mergedAppNodes;
});
const computedCategoriesWithNodes = computed(() => {
if (!state.activeNodeActions) return getCategoriesWithNodes(filteredMergedAppNodes.value);
return getCategoriesWithNodes(selectedNodeActions.value, state.activeNodeActions.displayName);
});
const selectedNodeActions = computed<INodeActionTypeDescription[]>(
() => state.activeNodeActions?.actions ?? [],
);
const isAppEventSubcategory = computed(() => state.selectedSubcategory === '*');
const isActionsActive = computed(() => state.activeNodeActions !== null);
const firstLevelItems = computed(() => (isRoot.value ? activeView.value.items : []));
const searchItems = computed<INodeCreateElement[]>(() => {
return state.activeNodeActions
? transformCreateElements(selectedNodeActions.value)
: transformCreateElements(filteredMergedAppNodes.value);
});
// If the user is in the root view, we want to show trigger nodes first
// otherwise we want to show them last
function sortActions(nodeCreateElements: INodeCreateElement[]): INodeCreateElement[] {
const elements = {
trigger: [] as INodeCreateElement[],
regular: [] as INodeCreateElement[],
};
nodeCreateElements.forEach((el) => {
const isTriggersCategory = el.type === 'category' && el.key === 'Triggers';
const isTriggerAction = el.type === 'action' && el.category === 'Triggers';
elements[isTriggersCategory || isTriggerAction ? 'trigger' : 'regular'].push(el);
});
if (selectedView.value === TRIGGER_NODE_FILTER) {
return [...elements.trigger, ...elements.regular];
}
return [...elements.regular, ...elements.trigger];
}
function transformCreateElements(
createElements: Array<INodeTypeDescription | INodeActionTypeDescription>,
): INodeCreateElement[] {
const sorted = [...createElements];
sorted.sort((a, b) => {
const textA = a.displayName.toLowerCase();
const textB = b.displayName.toLowerCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
return sorted.map((nodeType) => {
// N8n node is a special case since it's the only core node that is both trigger and regular
// if we have more cases like this we should add more robust logic
const isN8nNode = nodeType.name.includes(N8N_NODE_TYPE);
return {
type: 'node',
category: nodeType.codex?.categories,
key: nodeType.name,
properties: {
nodeType,
subcategory: state.activeNodeActions?.displayName ?? '',
},
includedByTrigger: isN8nNode || nodeType.group.includes('trigger'),
includedByRegular: isN8nNode || !nodeType.group.includes('trigger'),
};
});
}
function onNodeTypeSelected(nodeTypes: string[]) {
emit(
'nodeTypeSelected',
nodeTypes.length === 1 ? getNodeTypesWithManualTrigger(nodeTypes[0]) : nodeTypes,
);
}
function getCustomAPICallHintLocale(key: string) {
if (!state.activeNodeActions) return '';
const nodeNameTitle = state.activeNodeActions.displayName;
return instance?.proxy.$locale.baseText(`nodeCreator.actionsList.${key}` as BaseTextKey, {
interpolate: { nodeNameTitle },
});
}
function setActiveActionsNodeType(nodeType: INodeTypeDescription | null) {
state.activeNodeActions = nodeType;
if (nodeType) trackActionsView();
}
function onActionSelected(actionCreateElement: INodeCreateElement) {
const action = (actionCreateElement.properties as IActionItemProps).nodeType;
const actionUpdateData = getActionData(action);
emit('nodeTypeSelected', getNodeTypesWithManualTrigger(actionUpdateData.key));
setAddedNodeActionParameters(actionUpdateData, telemetry);
}
function addWebHookNode() {
emit('nodeTypeSelected', [WEBHOOK_NODE_TYPE]);
}
function addHttpNode(isAction: boolean) {
const updateData = {
name: '',
key: HTTP_REQUEST_NODE_TYPE,
value: {
authentication: 'predefinedCredentialType',
},
} as IUpdateInformation;
emit('nodeTypeSelected', [HTTP_REQUEST_NODE_TYPE]);
if (isAction) {
setAddedNodeActionParameters(updateData, telemetry, false);
const app_identifier = state.activeNodeActions?.name;
$externalHooks().run('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
}
}
function onSubcategorySelected(subcategory: INodeCreateElement) {
state.isRoot = false;
state.selectedSubcategory = subcategory.key;
}
function onSubcategoryClose(activeSubcategories: INodeCreateElement[]) {
if (isActionsActive.value === true) setActiveActionsNodeType(null);
state.isRoot = activeSubcategories.length === 0;
state.selectedSubcategory = activeSubcategories[activeSubcategories.length - 1]?.key ?? '';
}
function shouldShowNodeDescription(node: NodeCreateElement) {
return (node.category || []).includes(CORE_NODES_CATEGORY);
}
function shouldShowNodeActions(node: INodeCreateElement) {
if (state.isRoot && useNodeCreatorStore().itemsFilter === '') return false;
return true;
}
function trackActionsView() {
const trigger_action_count = selectedNodeActions.value.filter((action) =>
action.name.toLowerCase().includes('trigger'),
).length;
const trackingPayload = {
app_identifier: state.activeNodeActions?.name,
actions: selectedNodeActions.value.map((action) => action.displayName),
regular_action_count: selectedNodeActions.value.length - trigger_action_count,
trigger_action_count,
};
$externalHooks().run('nodeCreateList.onViewActions', trackingPayload);
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload);
}
onUnmounted(() => { onUnmounted(() => {
nodeCreatorStore.setSelectedType(ALL_NODE_FILTER); useNodeCreatorStore().resetRootViewHistory();
$externalHooks().run('nodeCreateList.destroyed');
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.destroyed', {
workflow_id: workflowId,
});
}); });
const { isRoot, activeNodeActions } = toRefs(state);
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.container { .mainPanel {
--node-icon-color: var(--color-text-base);
height: 100%; height: 100%;
display: flex;
flex-direction: column;
// Remove node item border on the root level
&.isRoot {
--node-item-border: none;
}
} }
.main-panel { .itemCreator {
height: 100%; height: calc(100% - 120px);
padding-top: 1px;
overflow-y: auto;
overflow-x: visible;
&::-webkit-scrollbar {
display: none;
}
}
.title {
font-size: var(--font-size-l);
line-height: var(--font-line-height-xloose);
font-weight: var(--font-weight-bold);
color: var(--color-text-dark);
}
.description {
font-size: var(--font-size-s);
line-height: var(--font-line-height-loose);
color: var(--color-text-base);
} }
</style> </style>

View file

@ -14,20 +14,17 @@
@mouseup="onMouseUp" @mouseup="onMouseUp"
data-test-id="node-creator" data-test-id="node-creator"
> >
<main-panel @nodeTypeSelected="$listeners.nodeTypeSelected" :searchItems="searchItems" /> <main-panel @nodeTypeSelected="$listeners.nodeTypeSelected" />
</div> </div>
</slide-transition> </slide-transition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch, reactive, toRefs } from 'vue'; import { watch, reactive, toRefs } from 'vue';
import { INodeCreateElement } from '@/Interface';
import SlideTransition from '@/components/transitions/SlideTransition.vue'; import SlideTransition from '@/components/transitions/SlideTransition.vue';
import MainPanel from './MainPanel.vue'; import MainPanel from './MainPanel.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useNodeCreatorStore } from '@/stores/nodeCreator'; import { useNodeCreatorStore } from '@/stores/nodeCreator';
export interface Props { export interface Props {
@ -46,28 +43,6 @@ const state = reactive({
mousedownInsideEvent: null as MouseEvent | null, mousedownInsideEvent: null as MouseEvent | null,
}); });
const visibleNodeTypes = computed(() => useNodeTypesStore().visibleNodeTypes);
const searchItems = computed<INodeCreateElement[]>(() => {
const sorted = [...visibleNodeTypes.value];
sorted.sort((a, b) => {
const textA = a.displayName.toLowerCase();
const textB = b.displayName.toLowerCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
return sorted.map((nodeType) => ({
type: 'node',
category: '',
key: `${nodeType.name}`,
properties: {
nodeType,
subcategory: '',
},
includedByTrigger: nodeType.group.includes('trigger'),
includedByRegular: !nodeType.group.includes('trigger'),
}));
});
function onClickOutside(event: Event) { function onClickOutside(event: Event) {
// We need to prevent cases where user would click inside the node creator // We need to prevent cases where user would click inside the node creator
// and try to drag undraggable element. In that case the click event would // and try to drag undraggable element. In that case the click event would

View file

@ -6,7 +6,7 @@
@dragend="onDragEnd" @dragend="onDragEnd"
@click.stop="onClick" @click.stop="onClick"
:class="$style.nodeItem" :class="$style.nodeItem"
:description="allowActions ? undefined : description" :description="allowDescription ? description : ''"
:title="displayName" :title="displayName"
:isTrigger="!allowActions && isTriggerNode" :isTrigger="!allowActions && isTriggerNode"
:show-action-arrow="showActionArrow" :show-action-arrow="showActionArrow"
@ -53,11 +53,13 @@ export interface Props {
nodeType: INodeTypeDescription; nodeType: INodeTypeDescription;
active?: boolean; active?: boolean;
allowActions?: boolean; allowActions?: boolean;
allowDescription?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
active: false, active: false,
allowActions: false, allowActions: false,
allowDescription: false,
}); });
const emit = defineEmits<{ const emit = defineEmits<{

View file

@ -80,7 +80,7 @@ defineExpose({
.searchContainer { .searchContainer {
display: flex; display: flex;
height: 40px; height: 40px;
padding: var(--spacing-s) var(--spacing-xs); padding: 0 var(--spacing-xs);
align-items: center; align-items: center;
margin: var(--search-margin, var(--spacing-s)); margin: var(--search-margin, var(--spacing-s));
filter: drop-shadow(0px 2px 5px rgba(46, 46, 50, 0.04)); filter: drop-shadow(0px 2px 5px rgba(46, 46, 50, 0.04));

View file

@ -1,97 +1,38 @@
<template> <template>
<div :class="{ [$style.subcategory]: true, [$style.subcategoryWithIcon]: hasIcon }"> <n8n-node-creator-node
<node-icon v-if="hasIcon" :class="$style.subcategoryIcon" :nodeType="itemProperties" /> :class="$style.subCategory"
<div :class="$style.details"> :title="$locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`)"
<div :class="$style.title"> :isTrigger="false"
{{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }} :description="$locale.baseText(`nodeCreator.subcategoryDescriptions.${subcategoryName}`)"
</div> :showActionArrow="true"
<div v-if="item.properties.description" :class="$style.description"> >
{{ $locale.baseText(`nodeCreator.subcategoryDescriptions.${subcategoryName}`) }} <template #icon>
</div> <n8n-node-icon type="icon" :name="item.icon" :circle="false" :showTooltip="false" />
</div> </template>
<div :class="$style.action"> </n8n-node-creator-node>
<font-awesome-icon :class="$style.arrow" icon="arrow-right" />
</div>
</div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import Vue, { PropType } from 'vue'; import { ISubcategoryItemProps } from '@/Interface';
import camelcase from 'lodash.camelcase'; import camelcase from 'lodash.camelcase';
import { computed } from 'vue';
export interface Props {
item: ISubcategoryItemProps;
}
import NodeIcon from '@/components/NodeIcon.vue'; const props = defineProps<Props>();
import { INodeCreateElement, ISubcategoryItemProps } from '@/Interface'; const subcategoryName = computed(() => camelcase(props.item.subcategory));
export default Vue.extend({
components: {
NodeIcon,
},
props: {
item: {
type: Object as PropType<INodeCreateElement>,
required: true,
},
},
computed: {
itemProperties(): ISubcategoryItemProps {
return this.item.properties as ISubcategoryItemProps;
},
subcategoryName(): string {
return camelcase(this.itemProperties.subcategory);
},
hasIcon(): boolean {
return this.itemProperties.icon !== undefined || this.itemProperties.iconData !== undefined;
},
},
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.subcategoryIcon { .subCategory {
min-width: 26px; --action-arrow-color: var(--color-text-light);
max-width: 26px;
margin-right: 15px;
}
.subcategory {
display: flex;
padding: 11px 16px 11px 30px;
user-select: none;
}
.subcategoryWithIcon {
margin-left: 15px; margin-left: 15px;
margin-right: 12px; margin-right: 12px;
padding: 11px 8px 11px 0;
} }
.withTopBorder {
.details { border-top: 1px solid var(--color-foreground-base);
flex-grow: 1; margin-top: var(--spacing-m);
margin-right: 4px; padding-top: var(--spacing-l);
}
.title {
font-size: 14px;
font-weight: var(--font-weight-bold);
line-height: 16px;
margin-bottom: 3px;
}
.description {
font-size: var(--font-size-2xs);
line-height: 16px;
font-weight: 400;
color: $node-creator-description-color;
}
.action {
display: flex;
align-items: center;
margin-left: var(--spacing-2xs);
}
.arrow {
font-size: 12px;
width: 12px;
color: $node-creator-arrow-color;
} }
</style> </style>

View file

@ -1,449 +0,0 @@
<template>
<div :class="{ [$style.triggerHelperContainer]: true, [$style.isRoot]: isRoot }">
<categorized-items
:expandAllCategories="isActionsActive"
:subcategoryOverride="nodeAppSubcategory"
:alwaysShowSearch="isActionsActive"
:categorizedItems="computedCategorizedItems"
:categoriesWithNodes="computedCategoriesWithNodes"
:initialActiveIndex="0"
:searchItems="searchItems"
:withActionsGetter="shouldShowNodeActions"
:firstLevelItems="firstLevelItems"
:showSubcategoryIcon="isActionsActive"
:flatten="!isActionsActive && isAppEventSubcategory"
:filterByType="false"
:lazyRender="true"
:allItems="allNodes"
:searchPlaceholder="searchPlaceholder"
ref="categorizedItemsRef"
@subcategoryClose="onSubcategoryClose"
@onSubcategorySelected="onSubcategorySelected"
@nodeTypeSelected="onNodeTypeSelected"
@actionsOpen="setActiveActionsNodeType"
@actionSelected="onActionSelected"
>
<template #noResultsTitle v-if="isActionsActive">
<i />
</template>
<template #noResultsAction v-if="isActionsActive">
<p
v-if="containsAPIAction"
v-html="getCustomAPICallHintLocale('apiCallNoResult')"
class="clickable"
@click.stop="addHttpNode"
/>
<p v-else v-text="$locale.baseText('nodeCreator.noResults.noMatchingActions')" />
</template>
<template #header>
<slot name="header" />
<p
v-if="isRoot"
v-text="$locale.baseText('nodeCreator.triggerHelperPanel.title')"
:class="$style.title"
/>
</template>
<template #footer v-if="activeNodeActions && containsAPIAction">
<slot name="footer" />
<span
v-html="getCustomAPICallHintLocale('apiCall')"
class="clickable"
@click.stop="addHttpNode"
/>
</template>
</categorized-items>
</div>
</template>
<script setup lang="ts">
import { reactive, toRefs, getCurrentInstance, computed, onMounted, ref } from 'vue';
import {
INodeTypeDescription,
INodeActionTypeDescription,
INodeTypeNameVersion,
} from 'n8n-workflow';
import {
INodeCreateElement,
IActionItemProps,
SubcategoryCreateElement,
IUpdateInformation,
} from '@/Interface';
import {
CORE_NODES_CATEGORY,
WEBHOOK_NODE_TYPE,
OTHER_TRIGGER_NODES_SUBCATEGORY,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
EMAIL_IMAP_NODE_TYPE,
CUSTOM_API_CALL_NAME,
HTTP_REQUEST_NODE_TYPE,
STICKY_NODE_TYPE,
} from '@/constants';
import CategorizedItems from './CategorizedItems.vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { getCategoriesWithNodes, getCategorizedList } from '@/utils';
import { externalHooks } from '@/mixins/externalHooks';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { BaseTextKey } from '@/plugins/i18n';
const instance = getCurrentInstance();
const items: INodeCreateElement[] = [
{
key: '*',
type: 'subcategory',
title: instance?.proxy.$locale.baseText('nodeCreator.subcategoryNames.appTriggerNodes'),
properties: {
subcategory: 'App Trigger Nodes',
description: instance?.proxy.$locale.baseText(
'nodeCreator.subcategoryDescriptions.appTriggerNodes',
),
icon: 'fa:satellite-dish',
defaults: {
color: '#7D838F',
},
},
},
{
key: SCHEDULE_TRIGGER_NODE_TYPE,
type: 'node',
properties: {
nodeType: {
group: [],
name: SCHEDULE_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.scheduleTriggerDescription',
),
icon: 'fa:clock',
defaults: {
color: '#7D838F',
},
},
},
},
{
key: WEBHOOK_NODE_TYPE,
type: 'node',
properties: {
nodeType: {
group: [],
name: WEBHOOK_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.webhookTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.webhookTriggerDescription',
),
iconData: {
type: 'file',
icon: 'webhook',
fileBuffer: '/static/webhook-icon.svg',
},
defaults: {
color: '#7D838F',
},
},
},
},
{
key: MANUAL_TRIGGER_NODE_TYPE,
type: 'node',
properties: {
nodeType: {
group: [],
name: MANUAL_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.manualTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.manualTriggerDescription',
),
icon: 'fa:mouse-pointer',
defaults: {
color: '#7D838F',
},
},
},
},
{
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
type: 'node',
properties: {
nodeType: {
group: [],
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.workflowTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.workflowTriggerDescription',
),
icon: 'fa:sign-out-alt',
defaults: {
color: '#7D838F',
},
},
},
},
{
type: 'subcategory',
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: OTHER_TRIGGER_NODES_SUBCATEGORY,
description: instance?.proxy.$locale.baseText(
'nodeCreator.subcategoryDescriptions.otherTriggerNodes',
),
icon: 'fa:folder-open',
defaults: {
color: '#7D838F',
},
},
},
];
const emit = defineEmits({
nodeTypeSelected: (nodeTypes: string[]) => true,
});
const state = reactive({
isRoot: true,
selectedSubcategory: '',
activeNodeActions: null as INodeTypeDescription | null,
latestNodeData: null as INodeTypeDescription | null,
});
const categorizedItemsRef = ref<InstanceType<typeof CategorizedItems>>();
const { $externalHooks } = new externalHooks();
const {
mergedAppNodes,
setShowTabs,
getActionData,
getNodeTypesWithManualTrigger,
setAddedNodeActionParameters,
} = useNodeCreatorStore();
const { getNodeType } = useNodeTypesStore();
const telemetry = instance?.proxy.$telemetry;
const { categorizedItems: allNodes, isTriggerNode } = useNodeTypesStore();
const containsAPIAction = computed(
() =>
activeNodeActions.value?.properties.some((p) =>
p.options?.find((o) => o.name === CUSTOM_API_CALL_NAME),
) === true,
);
const computedCategorizedItems = computed(() =>
getCategorizedList(computedCategoriesWithNodes.value, true),
);
const nodeAppSubcategory = computed<SubcategoryCreateElement | undefined>(() => {
if (!state.activeNodeActions) return undefined;
return {
type: 'subcategory',
properties: {
subcategory: state.activeNodeActions.displayName,
nodeType: {
description: '',
key: state.activeNodeActions.name,
iconUrl: state.activeNodeActions.iconUrl,
icon: state.activeNodeActions.icon,
},
},
};
});
const searchPlaceholder = computed(() => {
const nodeNameTitle = state.activeNodeActions?.displayName?.trim() as string;
const actionsSearchPlaceholder = instance?.proxy.$locale.baseText(
'nodeCreator.actionsCategory.searchActions',
{ interpolate: { nodeNameTitle } },
);
return isActionsActive.value ? actionsSearchPlaceholder : undefined;
});
const filteredMergedAppNodes = computed(() => {
const WHITELISTED_APP_CORE_NODES = [EMAIL_IMAP_NODE_TYPE, WEBHOOK_NODE_TYPE];
if (isAppEventSubcategory.value)
return mergedAppNodes.filter((node) => {
const isRegularNode = !isTriggerNode(node.name);
const isStickyNode = node.name === STICKY_NODE_TYPE;
const isCoreNode =
node.codex?.categories?.includes(CORE_NODES_CATEGORY) &&
!WHITELISTED_APP_CORE_NODES.includes(node.name);
const hasActions = (node.actions || []).length > 0;
if (isRegularNode && !hasActions) return false;
return !isCoreNode && !isStickyNode;
});
return mergedAppNodes;
});
const computedCategoriesWithNodes = computed(() => {
if (!state.activeNodeActions) return getCategoriesWithNodes(filteredMergedAppNodes.value, []);
return getCategoriesWithNodes(selectedNodeActions.value, [], state.activeNodeActions.displayName);
});
const selectedNodeActions = computed<INodeActionTypeDescription[]>(
() => state.activeNodeActions?.actions ?? [],
);
const isAppEventSubcategory = computed(() => state.selectedSubcategory === '*');
const isActionsActive = computed(() => state.activeNodeActions !== null);
const firstLevelItems = computed(() => (isRoot.value ? items : []));
const isSearchActive = computed(() => useNodeCreatorStore().itemsFilter !== '');
const searchItems = computed<INodeCreateElement[]>(() => {
const sorted = state.activeNodeActions
? [...selectedNodeActions.value]
: [...filteredMergedAppNodes.value];
sorted.sort((a, b) => {
const textA = a.displayName.toLowerCase();
const textB = b.displayName.toLowerCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
return sorted.map((nodeType) => ({
type: 'node',
category: '',
key: nodeType.name,
properties: {
nodeType,
subcategory: state.activeNodeActions ? state.activeNodeActions.displayName : '',
},
includedByTrigger: nodeType.group.includes('trigger'),
includedByRegular: !nodeType.group.includes('trigger'),
}));
});
function onNodeTypeSelected(nodeTypes: string[]) {
emit(
'nodeTypeSelected',
nodeTypes.length === 1 ? getNodeTypesWithManualTrigger(nodeTypes[0]) : nodeTypes,
);
}
function getCustomAPICallHintLocale(key: string) {
if (!state.activeNodeActions) return '';
const nodeNameTitle = state.activeNodeActions.displayName;
return instance?.proxy.$locale.baseText(`nodeCreator.actionsList.${key}` as BaseTextKey, {
interpolate: { nodeNameTitle },
});
}
function setActiveActionsNodeType(nodeType: INodeTypeDescription | null) {
state.activeNodeActions = nodeType;
setShowTabs(false);
if (nodeType) trackActionsView();
}
function onActionSelected(actionCreateElement: INodeCreateElement) {
const action = (actionCreateElement.properties as IActionItemProps).nodeType;
const actionUpdateData = getActionData(action);
emit('nodeTypeSelected', getNodeTypesWithManualTrigger(actionUpdateData.key));
setAddedNodeActionParameters(actionUpdateData, telemetry);
}
function addHttpNode() {
const app_identifier = state.activeNodeActions?.name;
let nodeCredentialType = '';
const nodeType = app_identifier ? getNodeType(app_identifier) : null;
if (nodeType && nodeType.credentials && nodeType.credentials.length > 0) {
nodeCredentialType = nodeType.credentials[0].name;
}
const updateData = {
name: '',
key: HTTP_REQUEST_NODE_TYPE,
value: {
authentication: 'predefinedCredentialType',
nodeCredentialType,
},
} as IUpdateInformation;
emit('nodeTypeSelected', [MANUAL_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
setAddedNodeActionParameters(updateData, telemetry, false);
$externalHooks().run('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
}
function onSubcategorySelected(subcategory: INodeCreateElement) {
state.isRoot = false;
state.selectedSubcategory = subcategory.key;
}
function onSubcategoryClose(activeSubcategories: INodeCreateElement[]) {
if (isActionsActive.value === true) setActiveActionsNodeType(null);
state.isRoot = activeSubcategories.length === 0;
state.selectedSubcategory = activeSubcategories[activeSubcategories.length - 1]?.key ?? '';
}
function shouldShowNodeActions(node: INodeCreateElement) {
if (isAppEventSubcategory.value) return true;
if (state.isRoot && !isSearchActive.value) return false;
// Do not show actions for core category when searching
if (node.type === 'node')
return !node.properties.nodeType.codex?.categories?.includes(CORE_NODES_CATEGORY);
return false;
}
function trackActionsView() {
const trigger_action_count = selectedNodeActions.value.filter((action) =>
action.name.toLowerCase().includes('trigger'),
).length;
const trackingPayload = {
app_identifier: state.activeNodeActions?.name,
actions: selectedNodeActions.value.map((action) => action.displayName),
regular_action_count: selectedNodeActions.value.length - trigger_action_count,
trigger_action_count,
};
$externalHooks().run('nodeCreateList.onViewActions', trackingPayload);
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload);
}
const { isRoot, activeNodeActions } = toRefs(state);
</script>
<style lang="scss" module>
.triggerHelperContainer {
height: 100%;
display: flex;
flex-direction: column;
// Remove node item border on the root level
&.isRoot {
--node-item-border: none;
}
}
.itemCreator {
height: calc(100% - 120px);
padding-top: 1px;
overflow-y: auto;
overflow-x: visible;
&::-webkit-scrollbar {
display: none;
}
}
.title {
font-size: var(--font-size-l);
line-height: var(--font-line-height-xloose);
font-weight: var(--font-weight-bold);
color: var(--color-text-dark);
padding: var(--spacing-s) var(--spacing-s) var(--spacing-3xs);
}
</style>

View file

@ -1,59 +0,0 @@
<template>
<div
class="type-selector"
v-if="nodeCreatorStore.showTabs"
data-test-id="node-creator-type-selector"
>
<el-tabs
stretch
:value="nodeCreatorStore.selectedType"
@input="nodeCreatorStore.setSelectedType"
>
<el-tab-pane
:label="$locale.baseText('nodeCreator.mainPanel.all')"
:name="ALL_NODE_FILTER"
></el-tab-pane>
<el-tab-pane
:label="$locale.baseText('nodeCreator.mainPanel.regular')"
:name="REGULAR_NODE_FILTER"
></el-tab-pane>
<el-tab-pane
:label="$locale.baseText('nodeCreator.mainPanel.trigger')"
:name="TRIGGER_NODE_FILTER"
></el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ALL_NODE_FILTER, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER } from '@/constants';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
const nodeCreatorStore = useNodeCreatorStore();
</script>
<style lang="scss" scoped>
::v-deep .el-tabs__item {
padding: 0;
}
::v-deep .el-tabs__active-bar {
height: 1px;
}
::v-deep .el-tabs__nav-wrap::after {
height: 1px;
}
.type-selector {
text-align: center;
background-color: $node-creator-select-background-color;
::v-deep .el-tabs > div {
margin-bottom: 0;
z-index: 1;
.el-tabs__nav {
height: 43px;
}
}
}
</style>

View file

@ -0,0 +1,45 @@
<template>
<n8n-node-creator-node
:class="{
[$style.view]: true,
[$style.withTopBorder]: view.withTopBorder,
}"
:title="view.title"
:isTrigger="false"
:description="view.description"
:showActionArrow="true"
>
<template #icon>
<n8n-node-icon
type="icon"
:name="view.icon"
:circle="false"
:showTooltip="false"
></n8n-node-icon>
</template>
</n8n-node-creator-node>
</template>
<script setup lang="ts">
import { ViewItemProps } from '@/Interface';
export interface Props {
view: ViewItemProps;
}
defineProps<Props>();
</script>
<style lang="scss" module>
.view {
--action-arrow-color: var(--color-text-light);
margin-left: 15px;
margin-right: 12px;
padding: 11px 4px 11px 0;
}
.withTopBorder {
border-top: 1px solid var(--color-foreground-base);
margin-top: var(--spacing-xs);
padding-top: var(--spacing-l);
}
</style>

View file

@ -0,0 +1,198 @@
import { getCurrentInstance, computed } from 'vue';
import {
CORE_NODES_CATEGORY,
WEBHOOK_NODE_TYPE,
OTHER_TRIGGER_NODES_SUBCATEGORY,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
SCHEDULE_TRIGGER_NODE_TYPE,
REGULAR_NODE_FILTER,
TRANSFORM_DATA_SUBCATEGORY,
FILES_SUBCATEGORY,
FLOWS_CONTROL_SUBCATEGORY,
HELPERS_SUBCATEGORY,
TRIGGER_NODE_FILTER,
} from '@/constants';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
export default () => {
const instance = getCurrentInstance();
const nodeCreatorStore = useNodeCreatorStore();
const VIEWS = [
{
value: REGULAR_NODE_FILTER,
title: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.whatHappensNext'),
items: [
{
key: '*',
type: 'subcategory',
properties: {
subcategory: 'App Regular Nodes',
icon: 'globe',
},
},
{
type: 'subcategory',
key: TRANSFORM_DATA_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: TRANSFORM_DATA_SUBCATEGORY,
icon: 'pen',
},
},
{
type: 'subcategory',
key: HELPERS_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: HELPERS_SUBCATEGORY,
icon: 'toolbox',
},
},
{
type: 'subcategory',
key: FLOWS_CONTROL_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: FLOWS_CONTROL_SUBCATEGORY,
icon: 'code-branch',
},
},
{
type: 'subcategory',
key: FILES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: FILES_SUBCATEGORY,
icon: 'file-alt',
},
},
{
key: TRIGGER_NODE_FILTER,
type: 'view',
properties: {
title: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.addAnotherTrigger',
),
icon: 'bolt',
withTopBorder: true,
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.addAnotherTriggerDescription',
),
},
},
],
},
{
value: TRIGGER_NODE_FILTER,
title: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.selectATrigger'),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.selectATriggerDescription',
),
items: [
{
key: '*',
type: 'subcategory',
properties: {
subcategory: 'App Trigger Nodes',
icon: 'satellite-dish',
},
},
{
key: SCHEDULE_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
nodeType: {
group: [],
name: SCHEDULE_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.scheduleTriggerDescription',
),
icon: 'fa:clock',
},
},
},
{
key: WEBHOOK_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
nodeType: {
group: [],
name: WEBHOOK_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.webhookTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.webhookTriggerDescription',
),
iconData: {
type: 'file',
icon: 'webhook',
fileBuffer: '/static/webhook-icon.svg',
},
},
},
},
{
key: MANUAL_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
nodeType: {
group: [],
name: MANUAL_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.manualTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.manualTriggerDescription',
),
icon: 'fa:mouse-pointer',
},
},
},
{
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
type: 'node',
category: [CORE_NODES_CATEGORY],
properties: {
nodeType: {
group: [],
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.workflowTriggerDisplayName',
),
description: instance?.proxy.$locale.baseText(
'nodeCreator.triggerHelperPanel.workflowTriggerDescription',
),
icon: 'fa:sign-out-alt',
},
},
},
{
type: 'subcategory',
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: OTHER_TRIGGER_NODES_SUBCATEGORY,
icon: 'folder-open',
},
},
],
},
];
const activeView = computed(() => {
return VIEWS.find((v) => v.value === nodeCreatorStore.selectedView) || VIEWS[0];
});
return {
activeView,
};
};

View file

@ -1,12 +1,8 @@
<template> <template>
<Modal <Modal
:name="PERSONALIZATION_MODAL_KEY" :name="PERSONALIZATION_MODAL_KEY"
:title=" :title="$locale.baseText('personalizationModal.customizeN8n')"
!submitted :subtitle="$locale.baseText('personalizationModal.theseQuestionsHelpUs')"
? $locale.baseText('personalizationModal.customizeN8n')
: $locale.baseText('personalizationModal.thanks')
"
:subtitle="!submitted ? $locale.baseText('personalizationModal.theseQuestionsHelpUs') : ''"
:centerTitle="true" :centerTitle="true"
:showClose="false" :showClose="false"
:eventBus="modalBus" :eventBus="modalBus"
@ -17,11 +13,7 @@
@enter="onSave" @enter="onSave"
> >
<template #content> <template #content>
<div v-if="submitted" :class="$style.submittedContainer"> <div :class="$style.container">
<img :class="$style.demoImage" :src="rootStore.baseUrl + 'suggestednodes.png'" />
<n8n-text>{{ $locale.baseText('personalizationModal.lookOutForThingsMarked') }}</n8n-text>
</div>
<div :class="$style.container" v-else>
<n8n-form-inputs <n8n-form-inputs
:inputs="survey" :inputs="survey"
:columnView="true" :columnView="true"
@ -33,16 +25,9 @@
<template #footer> <template #footer>
<div> <div>
<n8n-button <n8n-button
v-if="submitted"
@click="closeDialog"
:label="$locale.baseText('personalizationModal.getStarted')"
float="right"
/>
<n8n-button
v-else
@click="onSave" @click="onSave"
:loading="isSaving" :loading="isSaving"
:label="$locale.baseText('personalizationModal.continue')" :label="$locale.baseText('personalizationModal.getStarted')"
float="right" float="right"
/> />
</div> </div>
@ -156,7 +141,6 @@ export default mixins(showMessage, workflowHelpers).extend({
name: 'PersonalizationModal', name: 'PersonalizationModal',
data() { data() {
return { return {
submitted: false,
isSaving: false, isSaving: false,
PERSONALIZATION_MODAL_KEY, PERSONALIZATION_MODAL_KEY,
otherWorkAreaFieldVisible: false, otherWorkAreaFieldVisible: false,
@ -646,12 +630,12 @@ export default mixins(showMessage, workflowHelpers).extend({
} }
await this.fetchOnboardingPrompt(); await this.fetchOnboardingPrompt();
this.submitted = true;
} catch (e) { } catch (e) {
this.$showError(e, 'Error while submitting results'); this.$showError(e, 'Error while submitting results');
} }
this.$data.isSaving = false; this.$data.isSaving = false;
this.closeDialog();
}, },
async fetchOnboardingPrompt() { async fetchOnboardingPrompt() {
if ( if (
@ -695,17 +679,4 @@ export default mixins(showMessage, workflowHelpers).extend({
margin-bottom: var(--spacing-m); margin-bottom: var(--spacing-m);
} }
} }
.submittedContainer {
* {
margin-bottom: var(--spacing-2xs);
}
}
.demoImage {
border-radius: var(--border-radius-large);
border: var(--border-base);
width: 100%;
height: 140px;
}
</style> </style>

View file

@ -274,7 +274,7 @@ exports[`PersonalizationModal.vue > should render correctly 1`] = `
</div> </div>
<div class=\\"footer\\"> <div class=\\"footer\\">
<div><button aria-disabled=\\"false\\" aria-busy=\\"false\\" aria-live=\\"polite\\" class=\\"button button primary medium float-right\\"> <div><button aria-disabled=\\"false\\" aria-busy=\\"false\\" aria-live=\\"polite\\" class=\\"button button primary medium float-right\\">
<!----><span>Continue</span></button></div> <!----><span>Get started</span></button></div>
</div> </div>
</div> </div>
<!----> <!---->

View file

@ -103,6 +103,7 @@ export const JIRA_TRIGGER_NODE_TYPE = 'n8n-nodes-base.jiraTrigger';
export const MICROSOFT_EXCEL_NODE_TYPE = 'n8n-nodes-base.microsoftExcel'; export const MICROSOFT_EXCEL_NODE_TYPE = 'n8n-nodes-base.microsoftExcel';
export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger'; export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger';
export const MICROSOFT_TEAMS_NODE_TYPE = 'n8n-nodes-base.microsoftTeams'; export const MICROSOFT_TEAMS_NODE_TYPE = 'n8n-nodes-base.microsoftTeams';
export const N8N_NODE_TYPE = 'n8n-nodes-base.n8n';
export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp'; export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp';
export const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote'; export const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote';
export const NOTION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.notionTrigger'; export const NOTION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.notionTrigger';
@ -168,6 +169,10 @@ export const UNCATEGORIZED_CATEGORY = 'Miscellaneous';
export const UNCATEGORIZED_SUBCATEGORY = 'Helpers'; export const UNCATEGORIZED_SUBCATEGORY = 'Helpers';
export const PERSONALIZED_CATEGORY = 'Suggested Nodes'; export const PERSONALIZED_CATEGORY = 'Suggested Nodes';
export const OTHER_TRIGGER_NODES_SUBCATEGORY = 'Other Trigger Nodes'; export const OTHER_TRIGGER_NODES_SUBCATEGORY = 'Other Trigger Nodes';
export const TRANSFORM_DATA_SUBCATEGORY = 'Data Transformation';
export const FILES_SUBCATEGORY = 'Files';
export const FLOWS_CONTROL_SUBCATEGORY = 'Flow';
export const HELPERS_SUBCATEGORY = 'Helpers';
export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3'; export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3';

View file

@ -677,10 +677,10 @@
"node.discovery.pinData.canvas": "You can pin this output instead of waiting for a test event. Open node to do so.", "node.discovery.pinData.canvas": "You can pin this output instead of waiting for a test event. Open node to do so.",
"node.discovery.pinData.ndv": "You can pin this output instead of waiting for a test event.", "node.discovery.pinData.ndv": "You can pin this output instead of waiting for a test event.",
"nodeBase.clickToAddNodeOrDragToConnect": "Click to add node<br />or drag to connect", "nodeBase.clickToAddNodeOrDragToConnect": "Click to add node<br />or drag to connect",
"nodeCreator.actionsCategory.operations": "Operations", "nodeCreator.actionsCategory.actions": "Actions",
"nodeCreator.actionsCategory.onNewEvent": "On new {event} event", "nodeCreator.actionsCategory.onNewEvent": "On new {event} event",
"nodeCreator.actionsCategory.onEvent": "On {event}", "nodeCreator.actionsCategory.onEvent": "On {event}",
"nodeCreator.actionsCategory.recommended": "Recommended", "nodeCreator.actionsCategory.triggers": "Triggers",
"nodeCreator.actionsCategory.searchActions": "Search {nodeNameTitle} Actions...", "nodeCreator.actionsCategory.searchActions": "Search {nodeNameTitle} Actions...",
"nodeCreator.actionsList.apiCall": "Didn't find the right event? Make a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>", "nodeCreator.actionsList.apiCall": "Didn't find the right event? Make a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>",
"nodeCreator.actionsList.apiCallNoResult": "Nothing found — try making a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>", "nodeCreator.actionsList.apiCallNoResult": "Nothing found — try making a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>",
@ -712,26 +712,33 @@
"nodeCreator.noResults.webhook": "Webhook", "nodeCreator.noResults.webhook": "Webhook",
"nodeCreator.searchBar.searchNodes": "Search nodes...", "nodeCreator.searchBar.searchNodes": "Search nodes...",
"nodeCreator.subcategoryDescriptions.appTriggerNodes": "Runs the flow when something happens in an app like Telegram, Notion or Airtable", "nodeCreator.subcategoryDescriptions.appTriggerNodes": "Runs the flow when something happens in an app like Telegram, Notion or Airtable",
"nodeCreator.subcategoryDescriptions.dataTransformation": "Manipulate data fields, run code", "nodeCreator.subcategoryDescriptions.appRegularNodes": "Do something in an app or service like Google Sheets, Telegram or Notion",
"nodeCreator.subcategoryDescriptions.files": "Work with CSV, XML, text, images etc.", "nodeCreator.subcategoryDescriptions.dataTransformation": "Manipulate data, run JavaScript code, etc.",
"nodeCreator.subcategoryDescriptions.flow": "Branches, core triggers, merge data", "nodeCreator.subcategoryDescriptions.files": "CSV, XLS, XML, text, images, etc.",
"nodeCreator.subcategoryDescriptions.helpers": "HTTP Requests (API calls), date and time, scrape HTML", "nodeCreator.subcategoryDescriptions.flow": "IF, Switch, Wait, Compare and Merge data, etc.",
"nodeCreator.subcategoryDescriptions.otherTriggerNodes": "Runs the flow on workflow errors, file changes, and more", "nodeCreator.subcategoryDescriptions.helpers": "HTTP Requests (API Calls), date and time, scrape HTML, RSS, SSH, etc.",
"nodeCreator.subcategoryNames.appTriggerNodes": "On App Event", "nodeCreator.subcategoryDescriptions.otherTriggerNodes": "Runs the flow on workflow errors, file changes, etc.",
"nodeCreator.subcategoryNames.dataTransformation": "Data Transformation", "nodeCreator.subcategoryNames.appTriggerNodes": "On app event",
"nodeCreator.subcategoryNames.appRegularNodes": "Action in an app",
"nodeCreator.subcategoryNames.dataTransformation": "Data transformation",
"nodeCreator.subcategoryNames.files": "Files", "nodeCreator.subcategoryNames.files": "Files",
"nodeCreator.subcategoryNames.flow": "Flow", "nodeCreator.subcategoryNames.flow": "Flow",
"nodeCreator.subcategoryNames.helpers": "Helpers", "nodeCreator.subcategoryNames.helpers": "Helpers",
"nodeCreator.subcategoryNames.otherTriggerNodes": "Other ways...", "nodeCreator.subcategoryNames.otherTriggerNodes": "Other ways...",
"nodeCreator.subcategoryTitles.otherTriggerNodes": "Other Trigger Nodes", "nodeCreator.subcategoryTitles.otherTriggerNodes": "Other Trigger Nodes",
"nodeCreator.triggerHelperPanel.addAnotherTrigger": "Add another trigger",
"nodeCreator.triggerHelperPanel.addAnotherTriggerDescription": "Triggers start your workflow. Workflows can have multiple triggers.",
"nodeCreator.triggerHelperPanel.title": "When should this workflow run?", "nodeCreator.triggerHelperPanel.title": "When should this workflow run?",
"nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName": "On a Schedule", "nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName": "On a schedule",
"nodeCreator.triggerHelperPanel.scheduleTriggerDescription": "Runs the flow every day, hour, or custom interval", "nodeCreator.triggerHelperPanel.scheduleTriggerDescription": "Runs the flow every day, hour, or custom interval",
"nodeCreator.triggerHelperPanel.webhookTriggerDisplayName": "On Webhook Call", "nodeCreator.triggerHelperPanel.webhookTriggerDisplayName": "On webhook call",
"nodeCreator.triggerHelperPanel.webhookTriggerDescription": "Runs the flow when another app sends a webhook", "nodeCreator.triggerHelperPanel.webhookTriggerDescription": "Runs the flow when another app sends a webhook",
"nodeCreator.triggerHelperPanel.manualTriggerDisplayName": "Manually", "nodeCreator.triggerHelperPanel.manualTriggerDisplayName": "Manually",
"nodeCreator.triggerHelperPanel.manualTriggerDescription": "Runs the flow on clicking a button in n8n", "nodeCreator.triggerHelperPanel.manualTriggerDescription": "Runs the flow on clicking a button in n8n",
"nodeCreator.triggerHelperPanel.workflowTriggerDisplayName": "When Called By Another Workflow", "nodeCreator.triggerHelperPanel.whatHappensNext": "What happens next?",
"nodeCreator.triggerHelperPanel.selectATrigger": "Select a trigger",
"nodeCreator.triggerHelperPanel.selectATriggerDescription": "A trigger is a step that starts your workflow",
"nodeCreator.triggerHelperPanel.workflowTriggerDisplayName": "When called by another workflow",
"nodeCreator.triggerHelperPanel.workflowTriggerDescription": "Runs the flow when called by the Execute Workflow node from a different workflow", "nodeCreator.triggerHelperPanel.workflowTriggerDescription": "Runs the flow when called by the Execute Workflow node from a different workflow",
"nodeCredentials.createNew": "Create New Credential", "nodeCredentials.createNew": "Create New Credential",
"nodeCredentials.credentialFor": "Credential for {credentialType}", "nodeCredentials.credentialFor": "Credential for {credentialType}",
@ -929,7 +936,6 @@
"personalizationModal.it": "IT", "personalizationModal.it": "IT",
"personalizationModal.legal": "Legal", "personalizationModal.legal": "Legal",
"personalizationModal.lessThan20People": "Less than 20 people", "personalizationModal.lessThan20People": "Less than 20 people",
"personalizationModal.lookOutForThingsMarked": "Look out for things marked with a ✨. They are personalized to make n8n more relevant to you.",
"personalizationModal.managedServiceProvider": "Managed service provider", "personalizationModal.managedServiceProvider": "Managed service provider",
"personalizationModal.manufacturing": "Manufacturing", "personalizationModal.manufacturing": "Manufacturing",
"personalizationModal.marketing": "Marketing", "personalizationModal.marketing": "Marketing",

View file

@ -11,6 +11,7 @@ import {
faArrowRight, faArrowRight,
faAt, faAt,
faBan, faBan,
faBolt,
faBook, faBook,
faBoxOpen, faBoxOpen,
faBug, faBug,
@ -46,6 +47,7 @@ import {
faExternalLinkAlt, faExternalLinkAlt,
faExchangeAlt, faExchangeAlt,
faFile, faFile,
faFileAlt,
faFileArchive, faFileArchive,
faFileCode, faFileCode,
faFileDownload, faFileDownload,
@ -112,6 +114,7 @@ import {
faThumbtack, faThumbtack,
faTimes, faTimes,
faTimesCircle, faTimesCircle,
faToolbox,
faTrash, faTrash,
faUndo, faUndo,
faUnlink, faUnlink,
@ -140,6 +143,7 @@ addIcon(faArrowLeft);
addIcon(faArrowRight); addIcon(faArrowRight);
addIcon(faAt); addIcon(faAt);
addIcon(faBan); addIcon(faBan);
addIcon(faBolt);
addIcon(faBook); addIcon(faBook);
addIcon(faBoxOpen); addIcon(faBoxOpen);
addIcon(faBug); addIcon(faBug);
@ -176,6 +180,7 @@ addIcon(faExpandAlt);
addIcon(faExternalLinkAlt); addIcon(faExternalLinkAlt);
addIcon(faExchangeAlt); addIcon(faExchangeAlt);
addIcon(faFile); addIcon(faFile);
addIcon(faFileAlt);
addIcon(faFileArchive); addIcon(faFileArchive);
addIcon(faFileCode); addIcon(faFileCode);
addIcon(faFileDownload); addIcon(faFileDownload);
@ -243,6 +248,7 @@ addIcon(faThLarge);
addIcon(faThumbtack); addIcon(faThumbtack);
addIcon(faTimes); addIcon(faTimes);
addIcon(faTimesCircle); addIcon(faTimesCircle);
addIcon(faToolbox);
addIcon(faTrash); addIcon(faTrash);
addIcon(faUndo); addIcon(faUndo);
addIcon(faUnlink); addIcon(faUnlink);

View file

@ -14,16 +14,14 @@ import {
STORES, STORES,
MANUAL_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE,
CORE_NODES_CATEGORY, CORE_NODES_CATEGORY,
CALENDLY_TRIGGER_NODE_TYPE,
TRIGGER_NODE_FILTER, TRIGGER_NODE_FILTER,
WEBHOOK_NODE_TYPE,
STICKY_NODE_TYPE, STICKY_NODE_TYPE,
} from '@/constants'; } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes'; import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useWorkflowsStore } from './workflows'; import { useWorkflowsStore } from './workflows';
import { CUSTOM_API_CALL_KEY, ALL_NODE_FILTER } from '@/constants'; import { CUSTOM_API_CALL_KEY, ALL_NODE_FILTER } from '@/constants';
import { INodeCreatorState, INodeFilterType, IUpdateInformation } from '@/Interface'; import { INodeCreatorState, INodeFilterType, IUpdateInformation } from '@/Interface';
import { i18n } from '@/plugins/i18n'; import { BaseTextKey, i18n } from '@/plugins/i18n';
import { externalHooks } from '@/mixins/externalHooks'; import { externalHooks } from '@/mixins/externalHooks';
import { Telemetry } from '@/plugins/telemetry'; import { Telemetry } from '@/plugins/telemetry';
@ -42,7 +40,7 @@ const customNodeActionsParsers: {
(categoryItem): INodeActionTypeDescription => ({ (categoryItem): INodeActionTypeDescription => ({
...getNodeTypeBase( ...getNodeTypeBase(
nodeTypeDescription, nodeTypeDescription,
i18n.baseText('nodeCreator.actionsCategory.recommended'), i18n.baseText('nodeCreator.actionsCategory.triggers'),
), ),
actionKey: categoryItem.value as string, actionKey: categoryItem.value as string,
displayName: i18n.baseText('nodeCreator.actionsCategory.onEvent', { displayName: i18n.baseText('nodeCreator.actionsCategory.onEvent', {
@ -81,7 +79,9 @@ function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, category: st
iconUrl: nodeTypeDescription.iconUrl, iconUrl: nodeTypeDescription.iconUrl,
icon: nodeTypeDescription.icon, icon: nodeTypeDescription.icon,
version: [1], version: [1],
defaults: {}, defaults: {
...nodeTypeDescription.defaults,
},
inputs: [], inputs: [],
outputs: [], outputs: [],
properties: [], properties: [],
@ -104,10 +104,7 @@ function operationsCategory(
); );
const items = filteredOutItems.map((item: INodePropertyOptions) => ({ const items = filteredOutItems.map((item: INodePropertyOptions) => ({
...getNodeTypeBase( ...getNodeTypeBase(nodeTypeDescription, i18n.baseText('nodeCreator.actionsCategory.actions')),
nodeTypeDescription,
i18n.baseText('nodeCreator.actionsCategory.operations'),
),
actionKey: item.value as string, actionKey: item.value as string,
displayName: item.action ?? startCase(item.name), displayName: item.action ?? startCase(item.name),
description: item.description ?? '', description: item.description ?? '',
@ -123,9 +120,7 @@ function operationsCategory(
return items; return items;
} }
function recommendedCategory( function triggersCategory(nodeTypeDescription: INodeTypeDescription): INodeActionTypeDescription[] {
nodeTypeDescription: INodeTypeDescription,
): INodeActionTypeDescription[] {
const matchingKeys = ['event', 'events', 'trigger on']; const matchingKeys = ['event', 'events', 'trigger on'];
const isTrigger = nodeTypeDescription.displayName?.toLowerCase().includes('trigger'); const isTrigger = nodeTypeDescription.displayName?.toLowerCase().includes('trigger');
const matchedProperty = nodeTypeDescription.properties.find((property) => const matchedProperty = nodeTypeDescription.properties.find((property) =>
@ -141,7 +136,7 @@ function recommendedCategory(
{ {
...getNodeTypeBase( ...getNodeTypeBase(
nodeTypeDescription, nodeTypeDescription,
i18n.baseText('nodeCreator.actionsCategory.recommended'), i18n.baseText('nodeCreator.actionsCategory.triggers'),
), ),
actionKey: PLACEHOLDER_RECOMMENDED_ACTION_KEY, actionKey: PLACEHOLDER_RECOMMENDED_ACTION_KEY,
displayName: i18n.baseText('nodeCreator.actionsCategory.onNewEvent', { displayName: i18n.baseText('nodeCreator.actionsCategory.onNewEvent', {
@ -166,7 +161,7 @@ function recommendedCategory(
filteredOutItems.map((categoryItem: INodePropertyOptions) => ({ filteredOutItems.map((categoryItem: INodePropertyOptions) => ({
...getNodeTypeBase( ...getNodeTypeBase(
nodeTypeDescription, nodeTypeDescription,
i18n.baseText('nodeCreator.actionsCategory.recommended'), i18n.baseText('nodeCreator.actionsCategory.triggers'),
), ),
actionKey: categoryItem.value as string, actionKey: categoryItem.value as string,
displayName: displayName:
@ -247,19 +242,26 @@ function resourceCategories(
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, { export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
state: (): INodeCreatorState => ({ state: (): INodeCreatorState => ({
itemsFilter: '', itemsFilter: '',
showTabs: true,
showScrim: false, showScrim: false,
selectedType: ALL_NODE_FILTER, selectedView: TRIGGER_NODE_FILTER,
rootViewHistory: [],
}), }),
actions: { actions: {
setShowTabs(isVisible: boolean) {
this.showTabs = isVisible;
},
setShowScrim(isVisible: boolean) { setShowScrim(isVisible: boolean) {
this.showScrim = isVisible; this.showScrim = isVisible;
}, },
setSelectedType(selectedNodeType: INodeFilterType) { setSelectedView(selectedNodeType: INodeFilterType) {
this.selectedType = selectedNodeType; this.selectedView = selectedNodeType;
if (!this.rootViewHistory.includes(selectedNodeType)) {
this.rootViewHistory.push(selectedNodeType);
}
},
closeCurrentView() {
this.rootViewHistory.pop();
this.selectedView = this.rootViewHistory[this.rootViewHistory.length - 1];
},
resetRootViewHistory() {
this.rootViewHistory = [];
}, },
setFilter(search: string) { setFilter(search: string) {
this.itemsFilter = search; this.itemsFilter = search;
@ -295,16 +297,11 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
visibleNodesWithActions(): INodeTypeDescription[] { visibleNodesWithActions(): INodeTypeDescription[] {
const nodes = deepCopy(useNodeTypesStore().visibleNodeTypes); const nodes = deepCopy(useNodeTypesStore().visibleNodeTypes);
const nodesWithActions = nodes.map((node) => { const nodesWithActions = nodes.map((node) => {
const isCoreNode = node.codex?.categories?.includes(CORE_NODES_CATEGORY); node.actions = [
// Core nodes shouldn't support actions ...triggersCategory(node),
node.actions = [];
if (isCoreNode) return node;
node.actions.push(
...recommendedCategory(node),
...operationsCategory(node), ...operationsCategory(node),
...resourceCategories(node), ...resourceCategories(node),
); ];
return node; return node;
}); });
@ -322,20 +319,15 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
}) })
.reduce((acc: Record<string, INodeTypeDescription>, node: INodeTypeDescription) => { .reduce((acc: Record<string, INodeTypeDescription>, node: INodeTypeDescription) => {
const clonedNode = deepCopy(node); const clonedNode = deepCopy(node);
const isCoreNode = node.codex?.categories?.includes(CORE_NODES_CATEGORY);
const actions = node.actions || []; const actions = node.actions || [];
// Do not merge core nodes // Do not merge core nodes
const normalizedName = isCoreNode const normalizedName = node.name.toLowerCase().replace('trigger', '');
? node.name
: node.name.toLowerCase().replace('trigger', '');
const existingNode = acc[normalizedName]; const existingNode = acc[normalizedName];
if (existingNode) existingNode.actions?.push(...actions); if (existingNode) existingNode.actions?.push(...actions);
else acc[normalizedName] = clonedNode; else acc[normalizedName] = clonedNode;
if (!isCoreNode) { acc[normalizedName].displayName = node.displayName.replace('Trigger', '');
acc[normalizedName].displayName = node.displayName.replace('Trigger', '');
}
return acc; return acc;
}, {}); }, {});
@ -355,7 +347,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
const { workflowTriggerNodes } = useWorkflowsStore(); const { workflowTriggerNodes } = useWorkflowsStore();
const isTrigger = useNodeTypesStore().isTriggerNode(nodeType); const isTrigger = useNodeTypesStore().isTriggerNode(nodeType);
const workflowContainsTrigger = workflowTriggerNodes.length > 0; const workflowContainsTrigger = workflowTriggerNodes.length > 0;
const isTriggerPanel = useNodeCreatorStore().selectedType === TRIGGER_NODE_FILTER; const isTriggerPanel = useNodeCreatorStore().selectedView === TRIGGER_NODE_FILTER;
const isStickyNode = nodeType === STICKY_NODE_TYPE; const isStickyNode = nodeType === STICKY_NODE_TYPE;
const nodeTypes = const nodeTypes =

View file

@ -4,7 +4,6 @@ import { useNodeTypesStore } from './../stores/nodeTypes';
import { INodeCredentialDescription } from './../../../workflow/src/Interfaces'; import { INodeCredentialDescription } from './../../../workflow/src/Interfaces';
import { import {
CORE_NODES_CATEGORY, CORE_NODES_CATEGORY,
RECOMMENDED_CATEGORY,
CUSTOM_NODES_CATEGORY, CUSTOM_NODES_CATEGORY,
SUBCATEGORY_DESCRIPTIONS, SUBCATEGORY_DESCRIPTIONS,
UNCATEGORIZED_CATEGORY, UNCATEGORIZED_CATEGORY,
@ -72,7 +71,7 @@ const addNodeToCategory = (
} }
accu[category][subcategory].nodes.push({ accu[category][subcategory].nodes.push({
type: nodeType.actionKey ? 'action' : 'node', type: nodeType.actionKey ? 'action' : 'node',
key: `${category}_${nodeType.name}`, key: `${nodeType.name}`,
category, category,
properties: { properties: {
nodeType, nodeType,
@ -85,17 +84,12 @@ const addNodeToCategory = (
export const getCategoriesWithNodes = ( export const getCategoriesWithNodes = (
nodeTypes: INodeTypeDescription[], nodeTypes: INodeTypeDescription[],
personalizedNodeTypes: string[],
uncategorizedSubcategory = UNCATEGORIZED_SUBCATEGORY, uncategorizedSubcategory = UNCATEGORIZED_SUBCATEGORY,
): ICategoriesWithNodes => { ): ICategoriesWithNodes => {
const sorted = [...nodeTypes].sort((a: INodeTypeDescription, b: INodeTypeDescription) => const sorted = [...nodeTypes].sort((a: INodeTypeDescription, b: INodeTypeDescription) =>
a.displayName > b.displayName ? 1 : -1, a.displayName > b.displayName ? 1 : -1,
); );
const result = sorted.reduce((accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => { const result = sorted.reduce((accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => {
if (personalizedNodeTypes.includes(nodeType.name)) {
addNodeToCategory(accu, nodeType, PERSONALIZED_CATEGORY, uncategorizedSubcategory);
}
if (!nodeType.codex || !nodeType.codex.categories) { if (!nodeType.codex || !nodeType.codex.categories) {
addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, uncategorizedSubcategory); addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, uncategorizedSubcategory);
return accu; return accu;
@ -125,14 +119,12 @@ const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
CUSTOM_NODES_CATEGORY, CUSTOM_NODES_CATEGORY,
UNCATEGORIZED_CATEGORY, UNCATEGORIZED_CATEGORY,
PERSONALIZED_CATEGORY, PERSONALIZED_CATEGORY,
RECOMMENDED_CATEGORY,
]; ];
const categories = Object.keys(categoriesWithNodes); const categories = Object.keys(categoriesWithNodes);
const sorted = categories.filter((category: string) => !excludeFromSort.includes(category)); const sorted = categories.filter((category: string) => !excludeFromSort.includes(category));
sorted.sort(); sorted.sort();
return [ return [
RECOMMENDED_CATEGORY,
CORE_NODES_CATEGORY, CORE_NODES_CATEGORY,
CUSTOM_NODES_CATEGORY, CUSTOM_NODES_CATEGORY,
PERSONALIZED_CATEGORY, PERSONALIZED_CATEGORY,
@ -155,8 +147,9 @@ export const getCategorizedList = (
const categoryEl: INodeCreateElement = { const categoryEl: INodeCreateElement = {
type: 'category', type: 'category',
key: category, key: category,
category,
properties: { properties: {
category,
name: category,
expanded: categoryIsExpanded, expanded: categoryIsExpanded,
}, },
}; };
@ -179,7 +172,6 @@ export const getCategorizedList = (
const subcategoryEl: INodeCreateElement = { const subcategoryEl: INodeCreateElement = {
type: 'subcategory', type: 'subcategory',
key: `${category}_${subcategory}`, key: `${category}_${subcategory}`,
category,
properties: { properties: {
subcategory, subcategory,
description: SUBCATEGORY_DESCRIPTIONS[category][subcategory], description: SUBCATEGORY_DESCRIPTIONS[category][subcategory],
@ -277,15 +269,15 @@ export const executionDataToJson = (inputData: INodeExecutionData[]): IDataObjec
[], [],
); );
export const matchesSelectType = (el: INodeCreateElement, selectedType: string) => { export const matchesSelectType = (el: INodeCreateElement, selectedView: string) => {
if (selectedType === REGULAR_NODE_FILTER && el.includedByRegular) { if (selectedView === REGULAR_NODE_FILTER && el.includedByRegular) {
return true; return true;
} }
if (selectedType === TRIGGER_NODE_FILTER && el.includedByTrigger) { if (selectedView === TRIGGER_NODE_FILTER && el.includedByTrigger) {
return true; return true;
} }
return selectedType === ALL_NODE_FILTER; return selectedView === ALL_NODE_FILTER;
}; };
const matchesAlias = (nodeType: INodeTypeDescription, filter: string): boolean => { const matchesAlias = (nodeType: INodeTypeDescription, filter: string): boolean => {

View file

@ -196,6 +196,8 @@ import {
TRIGGER_NODE_FILTER, TRIGGER_NODE_FILTER,
EnterpriseEditionFeature, EnterpriseEditionFeature,
POSTHOG_ASSUMPTION_TEST, POSTHOG_ASSUMPTION_TEST,
REGULAR_NODE_FILTER,
MANUAL_TRIGGER_NODE_TYPE,
} from '@/constants'; } from '@/constants';
import { copyPaste } from '@/mixins/copyPaste'; import { copyPaste } from '@/mixins/copyPaste';
import { externalHooks } from '@/mixins/externalHooks'; import { externalHooks } from '@/mixins/externalHooks';
@ -752,10 +754,9 @@ export default mixins(
}, },
showTriggerCreator(source: string) { showTriggerCreator(source: string) {
if (this.createNodeActive) return; if (this.createNodeActive) return;
this.nodeCreatorStore.setSelectedType(TRIGGER_NODE_FILTER); this.nodeCreatorStore.setSelectedView(TRIGGER_NODE_FILTER);
this.nodeCreatorStore.setShowScrim(true); this.nodeCreatorStore.setShowScrim(true);
this.onToggleNodeCreator({ source, createNodeActive: true }); this.onToggleNodeCreator({ source, createNodeActive: true });
this.$nextTick(() => this.nodeCreatorStore.setShowTabs(false));
}, },
async openExecution(executionId: string) { async openExecution(executionId: string) {
this.startLoading(); this.startLoading();
@ -1799,6 +1800,7 @@ export default mixins(
options: AddNodeOptions = {}, options: AddNodeOptions = {},
showDetail = true, showDetail = true,
trackHistory = false, trackHistory = false,
isAutoAdd = false,
) { ) {
const nodeTypeData: INodeTypeDescription | null = const nodeTypeData: INodeTypeDescription | null =
this.nodeTypesStore.getNodeType(nodeTypeName); this.nodeTypesStore.getNodeType(nodeTypeName);
@ -1920,6 +1922,7 @@ export default mixins(
this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName }); this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName });
const trackProperties: ITelemetryTrackProperties = { const trackProperties: ITelemetryTrackProperties = {
node_type: nodeTypeName, node_type: nodeTypeName,
is_auto_add: isAutoAdd,
workflow_id: this.workflowsStore.workflowId, workflow_id: this.workflowsStore.workflowId,
drag_and_drop: options.dragAndDrop, drag_and_drop: options.dragAndDrop,
}; };
@ -2005,6 +2008,7 @@ export default mixins(
options: AddNodeOptions = {}, options: AddNodeOptions = {},
showDetail = true, showDetail = true,
trackHistory = false, trackHistory = false,
isAutoAdd = false,
) { ) {
if (!this.editAllowedCheck()) { if (!this.editAllowedCheck()) {
return; return;
@ -2016,7 +2020,13 @@ export default mixins(
this.historyStore.startRecordingUndo(); this.historyStore.startRecordingUndo();
const newNodeData = await this.injectNode(nodeTypeName, options, showDetail, trackHistory); const newNodeData = await this.injectNode(
nodeTypeName,
options,
showDetail,
trackHistory,
isAutoAdd,
);
if (!newNodeData) { if (!newNodeData) {
return; return;
} }
@ -3674,12 +3684,14 @@ export default mixins(
if (createNodeActive === this.createNodeActive) return; if (createNodeActive === this.createNodeActive) return;
// Default to the trigger tab in node creator if there's no trigger node yet // Default to the trigger tab in node creator if there's no trigger node yet
if (!this.containsTrigger) this.nodeCreatorStore.setSelectedType(TRIGGER_NODE_FILTER); this.nodeCreatorStore.setSelectedView(
this.containsTrigger ? REGULAR_NODE_FILTER : TRIGGER_NODE_FILTER,
);
this.createNodeActive = createNodeActive; this.createNodeActive = createNodeActive;
const mode = const mode =
this.nodeCreatorStore.selectedType === TRIGGER_NODE_FILTER ? 'trigger' : 'default'; this.nodeCreatorStore.selectedView === TRIGGER_NODE_FILTER ? 'trigger' : 'regular';
this.$externalHooks().run('nodeView.createNodeActiveChanged', { this.$externalHooks().run('nodeView.createNodeActiveChanged', {
source, source,
mode, mode,
@ -3697,11 +3709,14 @@ export default mixins(
dragAndDrop: boolean, dragAndDrop: boolean,
) { ) {
nodeTypes.forEach(({ nodeTypeName, position }, index) => { nodeTypes.forEach(({ nodeTypeName, position }, index) => {
const isManualTrigger = nodeTypeName === MANUAL_TRIGGER_NODE_TYPE;
const openNDV = !isManualTrigger && (nodeTypes.length === 1 || index > 0);
this.addNode( this.addNode(
nodeTypeName, nodeTypeName,
{ position, dragAndDrop }, { position, dragAndDrop },
nodeTypes.length === 1 || index > 0, openNDV,
true, true,
nodeTypes.length > 1 && index < 1,
); );
if (index === 0) return; if (index === 0) return;
// If there's more than one node, we want to connect them // If there's more than one node, we want to connect them
@ -3717,7 +3732,7 @@ export default mixins(
this.connectTwoNodes(previouslyAddedNode.name, 0, lastAddedNode.name, 0), this.connectTwoNodes(previouslyAddedNode.name, 0, lastAddedNode.name, 0),
); );
// Position the added node to the right side of the previsouly added one // Position the added node to the right side of the previously added one
lastAddedNode.position = [ lastAddedNode.position = [
previouslyAddedNode.position[0] + NodeViewUtils.NODE_SIZE * 2, previouslyAddedNode.position[0] + NodeViewUtils.NODE_SIZE * 2,
previouslyAddedNode.position[1], previouslyAddedNode.position[1],

View file

@ -22,56 +22,67 @@ const nodeOperations: INodePropertyOptions[] = [
name: 'Blur', name: 'Blur',
value: 'blur', value: 'blur',
description: 'Adds a blur to the image and so makes it less sharp', description: 'Adds a blur to the image and so makes it less sharp',
action: 'Blur Image',
}, },
{ {
name: 'Border', name: 'Border',
value: 'border', value: 'border',
description: 'Adds a border to the image', description: 'Adds a border to the image',
action: 'Border Image',
}, },
{ {
name: 'Composite', name: 'Composite',
value: 'composite', value: 'composite',
description: 'Composite image on top of another one', description: 'Composite image on top of another one',
action: 'Composite Image',
}, },
{ {
name: 'Create', name: 'Create',
value: 'create', value: 'create',
description: 'Create a new image', description: 'Create a new image',
action: 'Create Image',
}, },
{ {
name: 'Crop', name: 'Crop',
value: 'crop', value: 'crop',
description: 'Crops the image', description: 'Crops the image',
action: 'Crop Image',
}, },
{ {
name: 'Draw', name: 'Draw',
value: 'draw', value: 'draw',
description: 'Draw on image', description: 'Draw on image',
action: 'Draw Image',
}, },
{ {
name: 'Rotate', name: 'Rotate',
value: 'rotate', value: 'rotate',
description: 'Rotate image', description: 'Rotate image',
action: 'Rotate Image',
}, },
{ {
name: 'Resize', name: 'Resize',
value: 'resize', value: 'resize',
description: 'Change the size of image', description: 'Change the size of image',
action: 'Resize Image',
}, },
{ {
name: 'Shear', name: 'Shear',
value: 'shear', value: 'shear',
description: 'Shear image along the X or Y axis', description: 'Shear image along the X or Y axis',
action: 'Shear Image',
}, },
{ {
name: 'Text', name: 'Text',
value: 'text', value: 'text',
description: 'Adds text to image', description: 'Adds text to image',
action: 'Apply Text to Image',
}, },
{ {
name: 'Transparent', name: 'Transparent',
value: 'transparent', value: 'transparent',
description: 'Make a color in image transparent', description: 'Make a color in image transparent',
action: 'Add Transparency to Image',
}, },
]; ];

View file

@ -54,6 +54,7 @@ const versionDescription: INodeTypeDescription = {
{ {
name: 'Send', name: 'Send',
value: 'send', value: 'send',
action: 'Send an Email',
}, },
], ],
}, },

View file

@ -17,6 +17,6 @@
}, },
"alias": ["Workflow", "Execution"], "alias": ["Workflow", "Execution"],
"subcategories": { "subcategories": {
"Core Nodes": ["Helpers"] "Core Nodes": ["Helpers", "Flow", "Other Trigger Nodes"]
} }
} }

View file

@ -18,7 +18,7 @@ export class N8n implements INodeType {
group: ['transform'], group: ['transform'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume n8n API', description: 'Handle events and perform actions on your n8n instance',
defaults: { defaults: {
name: 'n8n', name: 'n8n',
}, },

View file

@ -11,6 +11,6 @@
] ]
}, },
"subcategories": { "subcategories": {
"Core Nodes": ["Flow", "Other Trigger Nodes"] "Core Nodes": ["Flow", "Other Trigger Nodes", "Helpers"]
} }
} }

View file

@ -10,7 +10,7 @@ export class N8nTrigger implements INodeType {
icon: 'file:n8nTrigger.svg', icon: 'file:n8nTrigger.svg',
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
description: 'Handle events from your n8n instance', description: 'Handle events and perform actions on your n8n instance',
eventTriggerDescription: '', eventTriggerDescription: '',
mockManualExecution: true, mockManualExecution: true,
defaults: { defaults: {

View file

@ -69,7 +69,7 @@ export class SpreadsheetFile implements INodeType {
name: 'Write to File', name: 'Write to File',
value: 'toFile', value: 'toFile',
description: 'Writes the workflow data to a spreadsheet file', description: 'Writes the workflow data to a spreadsheet file',
action: 'Write the workflow data to a spreadsheet file', action: 'Write data to a spreadsheet file',
}, },
], ],
default: 'fromFile', default: 'fromFile',