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(() => {
WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openInlineExpressionEditor();
@ -50,6 +50,7 @@ describe('Inline expression editor', () => {
});
it('should resolve array resolvables', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('[1, 2, 3]');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^\[Array: \[1,2,3\]\]$/);
@ -63,8 +64,9 @@ describe('Inline expression editor', () => {
});
it('should resolve $parameter[]', () => {
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().type('{{');
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', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < 2; i++) {
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, 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', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < 3; i++) {
WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true);
}
@ -235,6 +237,7 @@ describe('Canvas Actions', () => {
it('should move node', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
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', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
cy.get('body').type('{leftArrow}');
@ -326,6 +330,7 @@ describe('Canvas Actions', () => {
it('should select nodes using shift and arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
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', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().realHover();
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', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.drag(WorkflowPage.getters.getEndpointSelector('input', CODE_NODE_NAME), [0, -100]);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
@ -349,6 +356,7 @@ describe('Canvas Actions', () => {
it('should disable node by pressing the disable button', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters
.canvasNodes()
@ -360,6 +368,7 @@ describe('Canvas Actions', () => {
it('should disable node using keyboard shortcut', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().last().click();
WorkflowPage.actions.hitDisableNodeShortcut();
@ -368,6 +377,7 @@ describe('Canvas Actions', () => {
it('should disable multiple nodes', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.get('body').type('{esc}');
cy.get('body').type('{esc}');
@ -389,6 +399,7 @@ describe('Canvas Actions', () => {
it('should duplicate node', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters
.canvasNodes()

View file

@ -12,8 +12,7 @@ describe('Data pinning', () => {
});
it('Should be able to pin node output', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
workflowPage.getters.canvasNodes().first().dblclick();
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true});
ndv.getters.container().should('be.visible');
ndv.getters.pinDataButton().should('not.exist');
ndv.getters.editPinnedDataButton().should('be.visible');
@ -21,7 +20,9 @@ describe('Data pinning', () => {
ndv.actions.execute();
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.outputTableHeaders().should('have.length.at.least', 10);
ndv.getters.outputTableHeaders().first().should('include.text', 'timestamp');
@ -42,8 +43,7 @@ describe('Data pinning', () => {
});
it('Should be be able to set pinned data', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
workflowPage.getters.canvasNodes().first().dblclick();
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger', { keepNdvOpen: true});
ndv.getters.container().should('be.visible');
ndv.getters.pinDataButton().should('not.exist');
ndv.getters.editPinnedDataButton().should('be.visible');

View file

@ -26,7 +26,8 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input);
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', () => {
@ -40,7 +41,8 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input);
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', () => {
@ -54,7 +56,8 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input);
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', () => {
@ -68,7 +71,8 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input);
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', () => {
@ -82,7 +86,8 @@ describe('Data transformation expressions', () => {
ndv.getters.inlineExpressionEditorInput().clear().type(input);
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', () => {

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 ndv = new NDV();
const canvasNode = new CanvasNode();
describe('Data mapping', () => {
beforeEach(() => {
@ -20,7 +24,7 @@ describe('Data mapping', () => {
cy.fixture('Test_workflow-actions_paste-data.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
});
canvasNode.actions.openNode('Set');
workflowPage.actions.openNode('Set');
ndv.actions.executePrevious();
ndv.actions.switchInputMode('Table');
ndv.getters.inputDataContainer().get('table', { timeout: 10000 }).should('exist');
@ -42,7 +46,7 @@ describe('Data mapping', () => {
cy.get('body').paste(JSON.stringify(data));
});
canvasNode.actions.openNode('Set');
workflowPage.actions.openNode('Set');
ndv.actions.switchInputMode('Table');
ndv.getters.inputDataContainer().get('table', { timeout: 10000 }).should('exist');
@ -87,7 +91,7 @@ describe('Data mapping', () => {
cy.get('body').paste(JSON.stringify(data));
});
canvasNode.actions.openNode('Set');
workflowPage.actions.openNode('Set');
ndv.actions.switchInputMode('JSON');
ndv.getters.inputDataContainer().should('exist').find('.json-data')
@ -115,7 +119,7 @@ describe('Data mapping', () => {
cy.get('body').paste(JSON.stringify(data));
});
canvasNode.actions.openNode('Set');
workflowPage.actions.openNode('Set');
ndv.actions.clearParameterInput('value');
cy.get('body').type('{esc}');
@ -142,22 +146,22 @@ describe('Data mapping', () => {
it('maps expressions from previous nodes', () => {
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()
.find('span').contains('count')
.realMouseDown();
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')
.should('not.exist');
ndv.actions.switchInputMode('Table');
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')
.should('not.exist');
@ -175,8 +179,9 @@ describe('Data mapping', () => {
});
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([
{
input: [
@ -201,7 +206,7 @@ describe('Data mapping', () => {
ndv.actions.close();
workflowPage.actions.addNodeToCanvas('Item Lists');
canvasNode.actions.openNode('Item Lists');
workflowPage.actions.openNode('Item Lists');
ndv.getters.parameterInput('operation')
.click()

View file

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

View file

@ -1,13 +1,16 @@
import { NodeCreator } from '../pages/features/node-creator';
import { INodeTypeDescription } from 'n8n-workflow';
import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants';
import { randFirstName, randLastName } from '@ngneat/falso';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { NDV } from '../pages/ndv';
const email = DEFAULT_USER_EMAIL;
const password = DEFAULT_USER_PASSWORD;
const firstName = randFirstName();
const lastName = randLastName();
const nodeCreatorFeature = new NodeCreator();
const WorkflowPage = new WorkflowPageClass();
const NDVModal = new NDV();
describe('Node Creator', () => {
before(() => {
@ -27,30 +30,22 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters
.nodeCreator()
.contains('When should this workflow run?')
.contains('Select a trigger')
.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', () => {
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.getCreatorItem('On App Event').click();
nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'On App Event');
nodeCreatorFeature.getters.getCreatorItem('On app event').click();
nodeCreatorFeature.getters.activeSubcategory().should('have.text', 'On app event');
// Go back
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', () => {
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.selectedTab().should('have.text', 'Trigger');
nodeCreatorFeature.getters.searchBar().find('input').type('manual');
nodeCreatorFeature.getters.creatorItem().should('have.length', 1);
@ -72,92 +67,63 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
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.creatorItem().should('have.length', 0);
nodeCreatorFeature.getters
.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('Results in other categories (1)').should('exist');
nodeCreatorFeature.getters.creatorItem().should('have.length', 2);
nodeCreatorFeature.getters.getCreatorItem('Edit Image').should('exist');
nodeCreatorFeature.getters.selectedTab().should('have.text', 'All');
nodeCreatorFeature.getters.searchBar().find('button').click();
nodeCreatorFeature.getters.searchBar().find('input').should('be.empty');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image123123');
nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
});
it('should add manual trigger node', () => {
it('should check correct view panels', () => {
nodeCreatorFeature.getters.canvasAddButton().click();
nodeCreatorFeature.getters.getCreatorItem('Manually').click();
// TODO: Replace once we have canvas feature utils
cy.get('span').contains('Back to canvas').click();
WorkflowPage.actions.addNodeToCanvas('Manual', false);
nodeCreatorFeature.getters.canvasAddButton().should('not.be.visible');
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
// 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);
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.actions.selectTab('All');
const categories = Object.keys(categorizedNodes);
categories.forEach((category: string) => {
// Core Nodes contains subcategories which we'll test separately
if (category === 'Core Nodes') return;
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 add node to canvas from actions panel', () => {
const editImageNode = 'Edit Image';
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.searchBar().find('input').clear().type(editImageNode);
nodeCreatorFeature.getters.getCreatorItem(editImageNode).click();
nodeCreatorFeature.getters.activeSubcategory().should('have.text', editImageNode);
nodeCreatorFeature.getters.getCreatorItem('Crop Image').click();
NDVModal.getters.parameterInput('operation').should('contain.text', 'Crop');
})
it('should render and select community node', () => {
cy.intercept('GET', '/types/nodes.json').as('nodesIntercept');
cy.wait('@nodesIntercept').then(() => {
const customCategory = 'Custom Category';
const customNode = 'E2E Node';
const customNodeDescription = 'Demonstrate rendering of node';
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
.getCreatorItem(customNode)
.findChildByTestId('node-creator-item-tooltip')
.should('exist');
nodeCreatorFeature.getters
.getCreatorItem(customNode)
.contains(customNodeDescription)
.should('exist');
nodeCreatorFeature.actions.selectNode(customNode);
// 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', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
workflowPage.actions.addInitialNodeToCanvas('Manual');
workflowPage.getters.canvasNodes().first().dblclick();
ndv.getters.container().should('be.visible');
ndv.getters.backToCanvas().click();
@ -67,8 +67,8 @@ describe('NDV', () => {
});
it('should show validation errors only after blur or re-opening of NDV', () => {
workflowPage.actions.addNodeToCanvas('Manual Trigger');
workflowPage.actions.addNodeToCanvas('Airtable', true, true);
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('Airtable', true, true, 'Read data from a table');
ndv.getters.container().should('be.visible');
cy.get('.has-issues').should('have.length', 0);
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', () => {
WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Code');
WorkflowPage.actions.openNode('Code');
@ -23,7 +23,7 @@ describe('Code node', () => {
it('should execute the placeholder in each-item mode successfully', () => {
WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Code');
WorkflowPage.actions.openNode('Code');
ndv.getters.parameterInput('mode').click();

View file

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

View file

@ -10,13 +10,14 @@ describe('Expression editor modal', () => {
beforeEach(() => {
WorkflowPage.actions.visit();
WorkflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
WorkflowPage.actions.addInitialNodeToCanvas('Manual');
WorkflowPage.actions.addNodeToCanvas('Hacker News');
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openExpressionEditorModal();
});
it('should resolve primitive resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ 1 + 2');
WorkflowPage.getters.expressionModalOutput().contains(/^3$/);
WorkflowPage.getters.expressionModalInput().clear();
@ -31,6 +32,7 @@ describe('Expression editor modal', () => {
});
it('should resolve object resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters
.expressionModalInput()
.type('{{ { a : 1 }', { parseSpecialCharSequences: false });
@ -45,6 +47,7 @@ describe('Expression editor modal', () => {
});
it('should resolve array resolvables', () => {
WorkflowPage.getters.expressionModalInput().clear();
WorkflowPage.getters.expressionModalInput().type('{{ [1, 2, 3]');
WorkflowPage.getters.expressionModalOutput().contains(/^\[Array: \[1,2,3\]\]$/);
@ -55,7 +58,8 @@ describe('Expression editor modal', () => {
});
it('should resolve $parameter[]', () => {
WorkflowPage.getters.expressionModalInput().clear();
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) => {
this.getters.getCreatorItem(displayName).click();
},
selectTab: (tab: string) => {
this.getters.nodeCreatorTabs().contains(tab).click();
},
toggleCategory: (category: string) => {
this.getters.getCreatorItem(category).click();
},

View file

@ -9,4 +9,3 @@ export * from './settings-users';
export * from './settings-log-streaming';
export * from './sidebar';
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'),
pinDataButton: () => cy.getByTestId('ndv-pin-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'),
savePinnedDataButton: () => this.getters.runDataPaneHeader().find('button').contains('Save'),
outputTableRows: () => this.getters.outputDataContainer().find('table tr'),
@ -71,10 +71,9 @@ export class NDV extends BasePage {
setPinnedData: (data: object) => {
this.getters.editPinnedDataButton().click();
const editor = this.getters.pinnedDataEditor();
editor.click();
editor.type(`{selectall}{backspace}`);
editor.type(JSON.stringify(data).replace(new RegExp('{', 'g'), '{{}'));
this.getters.pinnedDataEditor().click();
this.getters.pinnedDataEditor().type(`{selectall}{backspace}`);
this.getters.pinnedDataEditor().type(JSON.stringify(data).replace(new RegExp('{', 'g'), '{{}'));
this.actions.savePinnedData();
},

View file

@ -24,7 +24,7 @@ export class WorkflowPage extends BasePage {
canvasPlusButton: () => cy.getByTestId('canvas-plus-button'),
canvasNodes: () => cy.getByTestId('canvas-node'),
canvasNodeByName: (nodeName: string) =>
this.getters.canvasNodes().filter(`:contains("${nodeName}")`),
this.getters.canvasNodes().filter(`:contains(${nodeName})`),
getEndpointSelector: (type: 'input' | 'output' | 'plus', nodeName: string, index = 0) => {
return `[data-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']`;
},
@ -124,6 +124,7 @@ export class WorkflowPage extends BasePage {
nodeDisplayName: string,
plusButtonClick = true,
preventNdvClose?: boolean,
action?: string,
) => {
if (plusButtonClick) {
this.getters.nodeCreatorPlusButton().click();
@ -131,11 +132,21 @@ export class WorkflowPage extends BasePage {
this.getters.nodeCreatorSearchBar().type(nodeDisplayName);
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}');
},
openNode: (nodeTypeName: string) => {
this.getters.canvasNodeByName(nodeTypeName).dblclick();
this.getters.canvasNodeByName(nodeTypeName).first().dblclick();
},
openExpressionEditorModal: () => {
cy.contains('Expression').invoke('show').click();

View file

@ -13,7 +13,7 @@
<div>
<div :class="$style.details">
<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
v-if="!!$slots.tooltip"
placement="top"
@ -36,7 +36,6 @@
<script setup lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import TriggerIcon from './TriggerIcon.vue';
import N8nTooltip from '../N8nTooltip';
export interface Props {
@ -60,14 +59,14 @@ defineEmits<{
align-items: center;
cursor: pointer;
z-index: 1;
padding: 11px 8px 11px 0;
padding: var(--spacing-xs) var(--spacing-2xs) var(--spacing-xs) 0;
&.hasAction {
user-select: none;
}
}
.creatorNode:hover .panelIcon {
color: var(--color-text-light);
color: var(--action-arrow-color-hover, var(--color-text-light));
}
.panelIcon {
@ -76,7 +75,7 @@ defineEmits<{
justify-content: flex-end;
align-items: center;
margin-left: var(--spacing-2xs);
color: var(--color-text-lighter);
color: var(--action-arrow-color, var(--color-text-lighter));
cursor: pointer;
background: transparent;
border: none;
@ -110,11 +109,12 @@ defineEmits<{
font-size: var(--font-size-2xs);
line-height: 1rem;
font-weight: 400;
color: var(--node-creator-description-colo, var(--color-text-base));
color: var(--node-creator-description-colos, var(--color-text-base));
}
.triggerIcon {
margin-left: var(--spacing-2xs);
margin-left: var(--spacing-3xs);
color: var(--color-primary);
}
</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 -->
<n8n-tooltip placement="top" :disabled="!showTooltip" v-if="showTooltip">
<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" />
<font-awesome-icon v-else :icon="name" :style="fontStyleData" />
</div>
@ -103,11 +103,11 @@ export default Vue.extend({
<style lang="scss" module>
.nodeIconWrapper {
width: 26px;
height: 26px;
width: var(--node-icon-size, 26px);
height: var(--node-icon-size, 26px);
border-radius: var(--border-radius-small);
color: #444;
line-height: 26px;
color: var(--node-icon-color, #444);
line-height: var(--node-icon-size, 26px);
font-size: 1.1em;
overflow: hidden;
text-align: center;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View file

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

View file

@ -1,6 +1,6 @@
<template>
<div
class="node-wrapper"
:class="{ 'node-wrapper': true, 'node-wrapper--trigger': isTriggerNode }"
:style="nodePosition"
:id="nodeId"
data-test-id="canvas-node"
@ -22,6 +22,9 @@
v-touch:start="touchStart"
v-touch:end="touchEnd"
>
<i class="trigger-icon">
<font-awesome-icon icon="bolt" size="lg" v-if="isTriggerNode" />
</i>
<div
v-if="!data.disabled"
:class="{ 'node-info-icon': true, 'shift-icon': shiftOutputCount }"
@ -196,6 +199,7 @@ import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { EnableNodeToggleCommand } from '@/models/history';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
export default mixins(
externalHooks,
@ -208,6 +212,7 @@ export default mixins(
name: 'Node',
components: {
TitledList,
FontAwesomeIcon,
NodeIcon,
},
props: {
@ -673,7 +678,6 @@ export default mixins(
border: 2px solid var(--color-foreground-xdark);
border-radius: var(--border-radius-large);
background-color: var(--color-background-xlight);
&.executing {
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 {
@ -810,6 +830,10 @@ export default mixins(
top: -8px !important;
height: 116px;
width: 116px !important;
.node-wrapper--trigger & {
border-radius: 36px 8px 8px 36px;
}
}
.disabled-linethrough {

View file

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

View file

@ -1,34 +1,18 @@
<template>
<div :class="$style.category">
<span :class="$style.name">
{{ renderCategoryName(item.category) }}{{ count !== undefined ? ` (${count})` : '' }}
</span>
<font-awesome-icon v-if="isExpanded" icon="chevron-down" :class="$style.arrow" />
<span :class="$style.name" v-text="item.name" />
<font-awesome-icon v-if="item.expanded" icon="chevron-down" :class="$style.arrow" />
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
</div>
</template>
<script lang="ts" setup>
import { computed, getCurrentInstance } from 'vue';
import camelcase from 'lodash.camelcase';
import { CategoryName } from '@/plugins/i18n';
import { INodeCreateElement, ICategoryItemProps } from '@/Interface';
import { ICategoryItemProps } from '@/Interface';
export interface Props {
item: INodeCreateElement;
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;
item: ICategoryItemProps;
}
defineProps<Props>();
</script>
<style lang="scss" module>

View file

@ -20,18 +20,15 @@
ref="iteratorItems"
@click="wrappedEmit('selected', item)"
>
<category-item
v-if="item.type === 'category'"
:item="item"
:count="enableGlobalCategoriesCounter ? getCategoryCount(item) : undefined"
/>
<category-item v-if="item.type === 'category'" :item="item.properties" />
<subcategory-item v-else-if="item.type === 'subcategory'" :item="item" />
<subcategory-item v-else-if="item.type === 'subcategory'" :item="item.properties" />
<node-item
v-else-if="item.type === 'node'"
:nodeType="item.properties.nodeType"
:allow-actions="withActionsGetter && withActionsGetter(item)"
:allow-description="withDescriptionGetter && withDescriptionGetter(item)"
@dragstart="wrappedEmit('dragstart', item, $event)"
@dragend="wrappedEmit('dragend', item, $event)"
@nodeTypeSelected="$listeners.nodeTypeSelected"
@ -45,6 +42,8 @@
@dragstart="wrappedEmit('dragstart', item, $event)"
@dragend="wrappedEmit('dragend', item, $event)"
/>
<view-item v-else-if="item.type === 'view'" :view="item.properties" />
</div>
<aside
v-for="item in elements.length"
@ -58,15 +57,13 @@
</template>
<script setup lang="ts">
import { INodeCreateElement, CategoryCreateElement, NodeCreateElement } from '@/Interface';
import { INodeCreateElement, NodeCreateElement } from '@/Interface';
import NodeItem from './NodeItem.vue';
import SubcategoryItem from './SubcategoryItem.vue';
import CategoryItem from './CategoryItem.vue';
import ActionItem from './ActionItem.vue';
import ViewItem from './ViewItem.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 {
elements: INodeCreateElement[];
@ -74,6 +71,7 @@ export interface Props {
disabled?: boolean;
lazyRender?: boolean;
withActionsGetter?: (element: NodeCreateElement) => boolean;
withDescriptionGetter?: (element: NodeCreateElement) => boolean;
enableGlobalCategoriesCounter?: boolean;
}
@ -102,25 +100,6 @@ function wrappedEmit(
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
// when loading many items.

View file

@ -1,100 +1,408 @@
<template>
<div class="container" ref="mainPanelContainer">
<div class="main-panel">
<trigger-helper-panel
v-if="nodeCreatorStore.selectedType === TRIGGER_NODE_FILTER"
@nodeTypeSelected="$listeners.nodeTypeSelected"
>
<template #header>
<type-selector />
</template>
</trigger-helper-panel>
<categorized-items
v-else
enable-global-categories-counter
:categorizedItems="categorizedItems"
:categoriesWithNodes="categoriesWithNodes"
:searchItems="searchItems"
:excludedSubcategories="[OTHER_TRIGGER_NODES_SUBCATEGORY]"
:initialActiveCategories="[CORE_NODES_CATEGORY]"
:allItems="categorizedItems"
@nodeTypeSelected="$listeners.nodeTypeSelected"
@actionsOpen="() => {}"
>
<template #header>
<type-selector />
</template>
</categorized-items>
</div>
<div :class="{ [$style.mainPanel]: true, [$style.isRoot]: isRoot }">
<CategorizedItems
:subcategoryOverride="nodeAppSubcategory"
:alwaysShowSearch="isActionsActive"
:hideOtherCategoryItems="isActionsActive"
:categorizedItems="computedCategorizedItems"
:searchItems="searchItems"
:withActionsGetter="shouldShowNodeActions"
:withDescriptionGetter="shouldShowNodeDescription"
:firstLevelItems="firstLevelItems"
:showSubcategoryIcon="isActionsActive"
:allItems="transformCreateElements(mergedAppNodes)"
:searchPlaceholder="searchPlaceholder"
@subcategoryClose="onSubcategoryClose"
@onSubcategorySelected="onSubcategorySelected"
@nodeTypeSelected="onNodeTypeSelected"
@actionsOpen="setActiveActionsNodeType"
@actionSelected="onActionSelected"
>
<template #noResults>
<no-results
data-test-id="categorized-no-results"
:showRequest="!isActionsActive"
:show-icon="!isActionsActive"
>
<template #title v-if="!isActionsActive">
<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>
</template>
<script setup lang="ts">
import { watch, getCurrentInstance, onMounted, onUnmounted } from 'vue';
import { externalHooks } from '@/mixins/externalHooks';
import TriggerHelperPanel from './TriggerHelperPanel.vue';
import { reactive, toRefs, getCurrentInstance, computed, onUnmounted, ref } from 'vue';
import {
INodeTypeDescription,
INodeActionTypeDescription,
INodeTypeNameVersion,
} from 'n8n-workflow';
import {
INodeCreateElement,
NodeCreateElement,
IActionItemProps,
SubcategoryCreateElement,
IUpdateInformation,
} from '@/Interface';
import {
ALL_NODE_FILTER,
TRIGGER_NODE_FILTER,
OTHER_TRIGGER_NODES_SUBCATEGORY,
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';
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 { getCategoriesWithNodes, getCategorizedList } from '@/utils';
import { externalHooks } from '@/mixins/externalHooks';
import { useNodeTypesStore } from '@/stores/nodeTypes';
export interface Props {
searchItems?: INodeCreateElement[];
}
withDefaults(defineProps<Props>(), {
searchItems: () => [],
});
import { BaseTextKey } from '@/plugins/i18n';
import NoResults from './NoResults.vue';
import { useRootStore } from '@/stores/n8nRootStore';
import useMainPanelView from './useMainPanelView';
const instance = getCurrentInstance();
const { $externalHooks } = new externalHooks();
const { workflowId } = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
const { categorizedItems, categoriesWithNodes } = useNodeTypesStore();
watch(
() => nodeCreatorStore.selectedType,
(newValue, oldValue) => {
$externalHooks().run('nodeCreateList.selectedTypeChanged', {
oldValue,
newValue,
});
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.selectedTypeChanged', {
old_filter: oldValue,
new_filter: newValue,
workflow_id: workflowId,
});
},
const emit = defineEmits({
nodeTypeSelected: (nodeTypes: string[]) => true,
});
const state = reactive({
isRoot: true,
selectedSubcategory: '',
activeNodeActions: null as INodeTypeDescription | null,
});
const { baseUrl } = useRootStore();
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(() => {
$externalHooks().run('nodeCreateList.mounted');
// Make sure tabs are visible on mount
nodeCreatorStore.setShowTabs(true);
const selectedView = computed(() => useNodeCreatorStore().selectedView);
const computedCategorizedItems = computed(() => {
if (isActionsActive.value) {
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(() => {
nodeCreatorStore.setSelectedType(ALL_NODE_FILTER);
$externalHooks().run('nodeCreateList.destroyed');
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.destroyed', {
workflow_id: workflowId,
});
useNodeCreatorStore().resetRootViewHistory();
});
const { isRoot, activeNodeActions } = toRefs(state);
</script>
<style lang="scss" scoped>
.container {
<style lang="scss" module>
.mainPanel {
--node-icon-color: var(--color-text-base);
height: 100%;
display: flex;
flex-direction: column;
// Remove node item border on the root level
&.isRoot {
--node-item-border: none;
}
}
.main-panel {
height: 100%;
.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);
}
.description {
font-size: var(--font-size-s);
line-height: var(--font-line-height-loose);
color: var(--color-text-base);
}
</style>

View file

@ -14,20 +14,17 @@
@mouseup="onMouseUp"
data-test-id="node-creator"
>
<main-panel @nodeTypeSelected="$listeners.nodeTypeSelected" :searchItems="searchItems" />
<main-panel @nodeTypeSelected="$listeners.nodeTypeSelected" />
</div>
</slide-transition>
</div>
</template>
<script setup lang="ts">
import { computed, watch, reactive, toRefs } from 'vue';
import { INodeCreateElement } from '@/Interface';
import { watch, reactive, toRefs } from 'vue';
import SlideTransition from '@/components/transitions/SlideTransition.vue';
import MainPanel from './MainPanel.vue';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
export interface Props {
@ -46,28 +43,6 @@ const state = reactive({
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) {
// 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

View file

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

View file

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

View file

@ -1,97 +1,38 @@
<template>
<div :class="{ [$style.subcategory]: true, [$style.subcategoryWithIcon]: hasIcon }">
<node-icon v-if="hasIcon" :class="$style.subcategoryIcon" :nodeType="itemProperties" />
<div :class="$style.details">
<div :class="$style.title">
{{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }}
</div>
<div v-if="item.properties.description" :class="$style.description">
{{ $locale.baseText(`nodeCreator.subcategoryDescriptions.${subcategoryName}`) }}
</div>
</div>
<div :class="$style.action">
<font-awesome-icon :class="$style.arrow" icon="arrow-right" />
</div>
</div>
<n8n-node-creator-node
:class="$style.subCategory"
:title="$locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`)"
:isTrigger="false"
:description="$locale.baseText(`nodeCreator.subcategoryDescriptions.${subcategoryName}`)"
:showActionArrow="true"
>
<template #icon>
<n8n-node-icon type="icon" :name="item.icon" :circle="false" :showTooltip="false" />
</template>
</n8n-node-creator-node>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
<script setup lang="ts">
import { ISubcategoryItemProps } from '@/Interface';
import camelcase from 'lodash.camelcase';
import { computed } from 'vue';
export interface Props {
item: ISubcategoryItemProps;
}
import NodeIcon from '@/components/NodeIcon.vue';
import { INodeCreateElement, ISubcategoryItemProps } from '@/Interface';
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;
},
},
});
const props = defineProps<Props>();
const subcategoryName = computed(() => camelcase(props.item.subcategory));
</script>
<style lang="scss" module>
.subcategoryIcon {
min-width: 26px;
max-width: 26px;
margin-right: 15px;
}
.subcategory {
display: flex;
padding: 11px 16px 11px 30px;
user-select: none;
}
.subcategoryWithIcon {
.subCategory {
--action-arrow-color: var(--color-text-light);
margin-left: 15px;
margin-right: 12px;
padding: 11px 8px 11px 0;
}
.details {
flex-grow: 1;
margin-right: 4px;
}
.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;
.withTopBorder {
border-top: 1px solid var(--color-foreground-base);
margin-top: var(--spacing-m);
padding-top: var(--spacing-l);
}
</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>
<Modal
:name="PERSONALIZATION_MODAL_KEY"
:title="
!submitted
? $locale.baseText('personalizationModal.customizeN8n')
: $locale.baseText('personalizationModal.thanks')
"
:subtitle="!submitted ? $locale.baseText('personalizationModal.theseQuestionsHelpUs') : ''"
:title="$locale.baseText('personalizationModal.customizeN8n')"
:subtitle="$locale.baseText('personalizationModal.theseQuestionsHelpUs')"
:centerTitle="true"
:showClose="false"
:eventBus="modalBus"
@ -17,11 +13,7 @@
@enter="onSave"
>
<template #content>
<div v-if="submitted" :class="$style.submittedContainer">
<img :class="$style.demoImage" :src="rootStore.baseUrl + 'suggestednodes.png'" />
<n8n-text>{{ $locale.baseText('personalizationModal.lookOutForThingsMarked') }}</n8n-text>
</div>
<div :class="$style.container" v-else>
<div :class="$style.container">
<n8n-form-inputs
:inputs="survey"
:columnView="true"
@ -33,16 +25,9 @@
<template #footer>
<div>
<n8n-button
v-if="submitted"
@click="closeDialog"
:label="$locale.baseText('personalizationModal.getStarted')"
float="right"
/>
<n8n-button
v-else
@click="onSave"
:loading="isSaving"
:label="$locale.baseText('personalizationModal.continue')"
:label="$locale.baseText('personalizationModal.getStarted')"
float="right"
/>
</div>
@ -156,7 +141,6 @@ export default mixins(showMessage, workflowHelpers).extend({
name: 'PersonalizationModal',
data() {
return {
submitted: false,
isSaving: false,
PERSONALIZATION_MODAL_KEY,
otherWorkAreaFieldVisible: false,
@ -646,12 +630,12 @@ export default mixins(showMessage, workflowHelpers).extend({
}
await this.fetchOnboardingPrompt();
this.submitted = true;
} catch (e) {
this.$showError(e, 'Error while submitting results');
}
this.$data.isSaving = false;
this.closeDialog();
},
async fetchOnboardingPrompt() {
if (
@ -695,17 +679,4 @@ export default mixins(showMessage, workflowHelpers).extend({
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>

View file

@ -274,7 +274,7 @@ exports[`PersonalizationModal.vue > should render correctly 1`] = `
</div>
<div class=\\"footer\\">
<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>
<!---->

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 MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger';
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 STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote';
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 PERSONALIZED_CATEGORY = 'Suggested 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';

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.ndv": "You can pin this output instead of waiting for a test event.",
"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.onEvent": "On {event}",
"nodeCreator.actionsCategory.recommended": "Recommended",
"nodeCreator.actionsCategory.triggers": "Triggers",
"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.apiCallNoResult": "Nothing found — try making a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>",
@ -712,26 +712,33 @@
"nodeCreator.noResults.webhook": "Webhook",
"nodeCreator.searchBar.searchNodes": "Search nodes...",
"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.files": "Work with CSV, XML, text, images etc.",
"nodeCreator.subcategoryDescriptions.flow": "Branches, core triggers, merge data",
"nodeCreator.subcategoryDescriptions.helpers": "HTTP Requests (API calls), date and time, scrape HTML",
"nodeCreator.subcategoryDescriptions.otherTriggerNodes": "Runs the flow on workflow errors, file changes, and more",
"nodeCreator.subcategoryNames.appTriggerNodes": "On App Event",
"nodeCreator.subcategoryNames.dataTransformation": "Data Transformation",
"nodeCreator.subcategoryDescriptions.appRegularNodes": "Do something in an app or service like Google Sheets, Telegram or Notion",
"nodeCreator.subcategoryDescriptions.dataTransformation": "Manipulate data, run JavaScript code, etc.",
"nodeCreator.subcategoryDescriptions.files": "CSV, XLS, XML, text, images, etc.",
"nodeCreator.subcategoryDescriptions.flow": "IF, Switch, Wait, Compare and Merge data, etc.",
"nodeCreator.subcategoryDescriptions.helpers": "HTTP Requests (API Calls), date and time, scrape HTML, RSS, SSH, etc.",
"nodeCreator.subcategoryDescriptions.otherTriggerNodes": "Runs the flow on workflow errors, file changes, etc.",
"nodeCreator.subcategoryNames.appTriggerNodes": "On app event",
"nodeCreator.subcategoryNames.appRegularNodes": "Action in an app",
"nodeCreator.subcategoryNames.dataTransformation": "Data transformation",
"nodeCreator.subcategoryNames.files": "Files",
"nodeCreator.subcategoryNames.flow": "Flow",
"nodeCreator.subcategoryNames.helpers": "Helpers",
"nodeCreator.subcategoryNames.otherTriggerNodes": "Other ways...",
"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.scheduleTriggerDisplayName": "On a Schedule",
"nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName": "On a schedule",
"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.manualTriggerDisplayName": "Manually",
"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",
"nodeCredentials.createNew": "Create New Credential",
"nodeCredentials.credentialFor": "Credential for {credentialType}",
@ -929,7 +936,6 @@
"personalizationModal.it": "IT",
"personalizationModal.legal": "Legal",
"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.manufacturing": "Manufacturing",
"personalizationModal.marketing": "Marketing",

View file

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

View file

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

View file

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

View file

@ -196,6 +196,8 @@ import {
TRIGGER_NODE_FILTER,
EnterpriseEditionFeature,
POSTHOG_ASSUMPTION_TEST,
REGULAR_NODE_FILTER,
MANUAL_TRIGGER_NODE_TYPE,
} from '@/constants';
import { copyPaste } from '@/mixins/copyPaste';
import { externalHooks } from '@/mixins/externalHooks';
@ -752,10 +754,9 @@ export default mixins(
},
showTriggerCreator(source: string) {
if (this.createNodeActive) return;
this.nodeCreatorStore.setSelectedType(TRIGGER_NODE_FILTER);
this.nodeCreatorStore.setSelectedView(TRIGGER_NODE_FILTER);
this.nodeCreatorStore.setShowScrim(true);
this.onToggleNodeCreator({ source, createNodeActive: true });
this.$nextTick(() => this.nodeCreatorStore.setShowTabs(false));
},
async openExecution(executionId: string) {
this.startLoading();
@ -1799,6 +1800,7 @@ export default mixins(
options: AddNodeOptions = {},
showDetail = true,
trackHistory = false,
isAutoAdd = false,
) {
const nodeTypeData: INodeTypeDescription | null =
this.nodeTypesStore.getNodeType(nodeTypeName);
@ -1920,6 +1922,7 @@ export default mixins(
this.$externalHooks().run('nodeView.addNodeButton', { nodeTypeName });
const trackProperties: ITelemetryTrackProperties = {
node_type: nodeTypeName,
is_auto_add: isAutoAdd,
workflow_id: this.workflowsStore.workflowId,
drag_and_drop: options.dragAndDrop,
};
@ -2005,6 +2008,7 @@ export default mixins(
options: AddNodeOptions = {},
showDetail = true,
trackHistory = false,
isAutoAdd = false,
) {
if (!this.editAllowedCheck()) {
return;
@ -2016,7 +2020,13 @@ export default mixins(
this.historyStore.startRecordingUndo();
const newNodeData = await this.injectNode(nodeTypeName, options, showDetail, trackHistory);
const newNodeData = await this.injectNode(
nodeTypeName,
options,
showDetail,
trackHistory,
isAutoAdd,
);
if (!newNodeData) {
return;
}
@ -3674,12 +3684,14 @@ export default mixins(
if (createNodeActive === this.createNodeActive) return;
// 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;
const mode =
this.nodeCreatorStore.selectedType === TRIGGER_NODE_FILTER ? 'trigger' : 'default';
this.nodeCreatorStore.selectedView === TRIGGER_NODE_FILTER ? 'trigger' : 'regular';
this.$externalHooks().run('nodeView.createNodeActiveChanged', {
source,
mode,
@ -3697,11 +3709,14 @@ export default mixins(
dragAndDrop: boolean,
) {
nodeTypes.forEach(({ nodeTypeName, position }, index) => {
const isManualTrigger = nodeTypeName === MANUAL_TRIGGER_NODE_TYPE;
const openNDV = !isManualTrigger && (nodeTypes.length === 1 || index > 0);
this.addNode(
nodeTypeName,
{ position, dragAndDrop },
nodeTypes.length === 1 || index > 0,
openNDV,
true,
nodeTypes.length > 1 && index < 1,
);
if (index === 0) return;
// 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),
);
// 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 = [
previouslyAddedNode.position[0] + NodeViewUtils.NODE_SIZE * 2,
previouslyAddedNode.position[1],

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,6 @@
]
},
"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',
group: ['trigger'],
version: 1,
description: 'Handle events from your n8n instance',
description: 'Handle events and perform actions on your n8n instance',
eventTriggerDescription: '',
mockManualExecution: true,
defaults: {

View file

@ -69,7 +69,7 @@ export class SpreadsheetFile implements INodeType {
name: 'Write to File',
value: 'toFile',
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',