feat(editor, core, cli): implement new workflow experience (#4358)

* feat(ExecuteWorkflowTrigger node): Implement ExecuteWorkflowTrigger node (#4108)

* feat(ExecuteWorkflowTrigger node): Implement ExecuteWorkflowTrigger node

* feat(editor): Do not show duplicate button if canvas contains `maxNodes` amount of nodes

* feat(ManualTrigger node): Implement ManualTrigger node (#4110)

* feat(ManualTrigger node): Implement ManualTrigger node

* 📝 Remove generics doc items from ManualTrigger node

* feat(editor-ui): Trigger tab redesign (#4150)

* 🚧 Begin with TriggerPanel implementation, add Other Trigger Nodes subcategory

* 🚧 Extracted categorized categories/subcategory/nodes rendering into its own component — CategorizedItems, removed SubcategoryPanel, added translations

*  Implement MainPanel background scrim

* ♻️ Move `categoriesWithNodes`, 'visibleNodeTypes` and 'categorizedItems` to store, implemented dynamic categories count based on `selectedType`

* 🐛 Fix SlideTransition for all the NodeCreato panels

* 💄 Fix cursos for CategoryItem and NodeItem

* 🐛 Make sure ALL_NODE_FILTER is always set when MainPanel is mounted

* 🎨 Address PR comments

* label: Use Array type for CategorizedItems props

* 🏷️ Add proper types for Vue props

* 🎨 Use standard component registration for CategorizedItems inside TriggerHelperPanel

* 🎨 Use kebab case for main-panel and icon component

* 🏷️ Improve types

* feat(editor-ui): Redesign search input inside node creator panel (#4204)

* 🚧 Begin with TriggerPanel implementation, add Other Trigger Nodes subcategory

* 🚧 Extracted categorized categories/subcategory/nodes rendering into its own component — CategorizedItems, removed SubcategoryPanel, added translations

*  Implement MainPanel background scrim

* ♻️ Move `categoriesWithNodes`, 'visibleNodeTypes` and 'categorizedItems` to store, implemented dynamic categories count based on `selectedType`

* 🐛 Fix SlideTransition for all the NodeCreato panels

* 💄 Fix cursos for CategoryItem and NodeItem

* 🐛 Make sure ALL_NODE_FILTER is always set when MainPanel is mounted

* 🎨 Address PR comments

* label: Use Array type for CategorizedItems props

* 🏷️ Add proper types for Vue props

* 🎨 Use standard component registration for CategorizedItems inside TriggerHelperPanel

*  Redesign search input and unify usage of categorized items

* 🏷️ Use lowercase "Boolean" as `isSearchVisible` computed return type

* 🔥 Remove useless emit

*  Implement no result view based on subcategory, minor fixes

* 🎨 Remove unused properties

* feat(node-email): Change EmailReadImap display name and name (#4239)

* feat(editor-ui):  Implement "Choose a Triger" action and related behaviour (#4226)

*  Implement "Choose a Triger" action and related behaviour

* 🔇 Lint fix

* ♻️ Remove PlaceholderTrigger node, add a button instead

* 🎨 Merge onMouseEnter and onMouseLeave to a single function

* 💡 Add comment

* 🔥 Remove PlaceholderNode registration

* 🎨 Rename TriggerPlaceholderButton to CanvasAddButton

*  Add method to unregister custom action and rework CanvasAddButton centering logic

* 🎨 Run `setRecenteredCanvasAddButtonPosition` on `CanvasAddButton` mount

* fix(editor): Fix selecting of node from node-creator panel by clicking

* 🔀 Merge fixes

* fix(editor): Show execute workflow trigger instead of workflow trigger in the trigger helper panel

* feat(editor): Fix node creator panel slide transition (#4261)

* fix(editor): Fix node creator panel slide-in/slide-out transitions

* 🎨 Fix naming

* 🎨 Use kebab-case for transition component name

* feat(editor): Disable execution and show notice when user tries to run workflow without enabled triggers

* fix(editor): Address first batch of new WF experience review (#4279)

* fix(editor): Fix first batch of review items

* bug(editor): Fix nodeview canvas add button centering

* 🔇 Fix linter errors

* bug(ManualTrigger Node): Fix manual trigger node execution

* fix(editor): Do not show canvas add button in execution or demo mode and prevent clicking if creator is open

* fix(editor): do not show pin data tooltip for manual trigger node

* fix(editor): do not use nodeViewOffset on zoomToFit

* 💄 Add margin for last node creator item and set font-weight to 700 for category title

*  Position welcome note next to the added trigger node

* 🐛 Remve always true welcome note

* feat(editor): Minor UI and UX tweaks (#4328)

* 💄 Make top viewport buttons less prominent

*  Allow user to switch to all tabs if it contains filter results, move nodecreator state props to its own module

* 🔇 Fix linting errors

* 🔇 Fix linting errors

* 🔇 Fix linting errors

* chore(build): Ping Turbo version to 1.5.5

* 💄 Minor traigger panel and node view style changes

* 💬 Update display name of execute workflow trigger

* feat(core, editor): Update subworkflow execution logic (#4269)

*  Implement `findWorkflowStart`

*  Extend `WorkflowOperationError`

*  Add `WorkflowOperationError` to toast

* 📘 Extend interface

*  Add `subworkflowExecutionError` to store

*  Create `SubworkflowOperationError`

*  Render subworkflow error as node error

* 🚚 Move subworkflow start validation to `cli`

*  Reset subworkflow execution error state

* 🔥 Remove unused import

*  Adjust CLI commands

* 🔥 Remove unneeded check

* 🔥 Remove stray log

*  Simplify syntax

*  Sort in case both Start and EWT present

* ♻️ Address Omar's feedback

* 🔥 Remove unneeded lint exception

* ✏️ Fix copy

* 👕 Fix lint

* fix: moved find start node function to catchable place

Co-authored-by: Omar Ajoue <krynble@gmail.com>

* 💄 Change ExecuteWorkflow node to primary

*  Allow user to navigate to all tab if it contains search results

* 🐛 Fixed canvas control button while in demo, disable workflow activation for non-activavle nodes and revert zoomToFit bottom offset

* :fix: Do not chow request text if there's results

* 💬 Update noResults text

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
OlegIvaniv 2022-10-18 14:23:22 +02:00 committed by GitHub
parent 128c3b83df
commit dae01f3abe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 2195 additions and 969 deletions

12
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "n8n", "name": "n8n",
"version": "0.198.0", "version": "0.198.2",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "n8n", "name": "n8n",
"version": "0.198.0", "version": "0.198.2",
"hasInstallScript": true, "hasInstallScript": true,
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
@ -43375,7 +43375,7 @@
}, },
"packages/cli": { "packages/cli": {
"name": "n8n", "name": "n8n",
"version": "0.198.0", "version": "0.198.2",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"dependencies": { "dependencies": {
"@oclif/command": "^1.5.18", "@oclif/command": "^1.5.18",
@ -43422,7 +43422,7 @@
"lodash.unset": "^4.5.2", "lodash.unset": "^4.5.2",
"mysql2": "~2.3.0", "mysql2": "~2.3.0",
"n8n-core": "~0.138.0", "n8n-core": "~0.138.0",
"n8n-editor-ui": "~0.164.0", "n8n-editor-ui": "~0.164.2",
"n8n-nodes-base": "~0.196.0", "n8n-nodes-base": "~0.196.0",
"n8n-workflow": "~0.120.0", "n8n-workflow": "~0.120.0",
"nodemailer": "^6.7.1", "nodemailer": "^6.7.1",
@ -45822,7 +45822,7 @@
}, },
"packages/editor-ui": { "packages/editor-ui": {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "0.164.0", "version": "0.164.2",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.1.0", "@codemirror/autocomplete": "^6.1.0",
@ -72169,7 +72169,7 @@
"lodash.unset": "^4.5.2", "lodash.unset": "^4.5.2",
"mysql2": "~2.3.0", "mysql2": "~2.3.0",
"n8n-core": "~0.138.0", "n8n-core": "~0.138.0",
"n8n-editor-ui": "~0.164.0", "n8n-editor-ui": "~0.164.2",
"n8n-nodes-base": "~0.196.0", "n8n-nodes-base": "~0.196.0",
"n8n-workflow": "~0.120.0", "n8n-workflow": "~0.120.0",
"nodemailer": "^6.7.1", "nodemailer": "^6.7.1",

View file

@ -4,7 +4,7 @@
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { Command, flags } from '@oclif/command'; import { Command, flags } from '@oclif/command';
import { BinaryDataManager, UserSettings, PLACEHOLDER_EMPTY_WORKFLOW_ID } from 'n8n-core'; import { BinaryDataManager, UserSettings, PLACEHOLDER_EMPTY_WORKFLOW_ID } from 'n8n-core';
import { INode, LoggerProxy } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow';
import { import {
ActiveExecutions, ActiveExecutions,
@ -25,6 +25,7 @@ import {
import { getLogger } from '../src/Logger'; import { getLogger } from '../src/Logger';
import config from '../config'; import config from '../config';
import { getInstanceOwner } from '../src/UserManagement/UserManagementHelper'; import { getInstanceOwner } from '../src/UserManagement/UserManagementHelper';
import { findCliWorkflowStart } from '../src/utils';
export class Execute extends Command { export class Execute extends Command {
static description = '\nExecutes a given workflow'; static description = '\nExecutes a given workflow';
@ -116,6 +117,10 @@ export class Execute extends Command {
} }
} }
if (!workflowData) {
throw new Error('Failed to retrieve workflow data for requested workflow');
}
// Make sure the settings exist // Make sure the settings exist
await UserSettings.prepareUserSettings(); await UserSettings.prepareUserSettings();
@ -144,33 +149,14 @@ export class Execute extends Command {
workflowId = undefined; workflowId = undefined;
} }
// Check if the workflow contains the required "Start" node
// "requiredNodeTypes" are also defined in editor-ui/views/NodeView.vue
const requiredNodeTypes = ['n8n-nodes-base.start'];
let startNode: INode | undefined;
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-non-null-assertion
for (const node of workflowData!.nodes) {
if (requiredNodeTypes.includes(node.type)) {
startNode = node;
break;
}
}
if (startNode === undefined) {
// If the workflow does not contain a start-node we can not know what
// should be executed and with which data to start.
console.info(`The workflow does not contain a "Start" node. So it can not be executed.`);
// eslint-disable-next-line consistent-return
return Promise.resolve();
}
try { try {
const startingNode = findCliWorkflowStart(workflowData.nodes);
const user = await getInstanceOwner(); const user = await getInstanceOwner();
const runData: IWorkflowExecutionDataProcess = { const runData: IWorkflowExecutionDataProcess = {
executionMode: 'cli', executionMode: 'cli',
startNodes: [startNode.name], startNodes: [startingNode.name],
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion workflowData,
workflowData: workflowData!,
userId: user.id, userId: user.id,
}; };
@ -207,6 +193,7 @@ export class Execute extends Command {
logger.error('\nExecution error:'); logger.error('\nExecution error:');
logger.info('===================================='); logger.info('====================================');
logger.error(e.message); logger.error(e.message);
if (e.description) logger.error(e.description);
logger.error(e.stack); logger.error(e.stack);
this.exit(1); this.exit(1);
} }

View file

@ -39,6 +39,7 @@ import {
import config from '../config'; import config from '../config';
import { User } from '../src/databases/entities/User'; import { User } from '../src/databases/entities/User';
import { getInstanceOwner } from '../src/UserManagement/UserManagementHelper'; import { getInstanceOwner } from '../src/UserManagement/UserManagementHelper';
import { findCliWorkflowStart } from '../src/utils';
export class ExecuteBatch extends Command { export class ExecuteBatch extends Command {
static description = '\nExecutes multiple workflows once'; static description = '\nExecutes multiple workflows once';
@ -613,16 +614,6 @@ export class ExecuteBatch extends Command {
coveredNodes: {}, coveredNodes: {},
}; };
const requiredNodeTypes = ['n8n-nodes-base.start'];
let startNode: INode | undefined;
// eslint-disable-next-line no-restricted-syntax
for (const node of workflowData.nodes) {
if (requiredNodeTypes.includes(node.type)) {
startNode = node;
break;
}
}
// We have a cool feature here. // We have a cool feature here.
// On each node, on the Settings tab in the node editor you can change // On each node, on the Settings tab in the node editor you can change
// the `Notes` field to add special cases for comparison and snapshots. // the `Notes` field to add special cases for comparison and snapshots.
@ -659,14 +650,6 @@ export class ExecuteBatch extends Command {
}); });
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
if (startNode === undefined) {
// If the workflow does not contain a start-node we can not know what
// should be executed and with which data to start.
executionResult.error = 'Workflow cannot be started as it does not contain a "Start" node.';
executionResult.executionStatus = 'warning';
resolve(executionResult);
}
let gotCancel = false; let gotCancel = false;
// Timeouts execution after 5 minutes. // Timeouts execution after 5 minutes.
@ -678,9 +661,11 @@ export class ExecuteBatch extends Command {
}, ExecuteBatch.executionTimeout); }, ExecuteBatch.executionTimeout);
try { try {
const startingNode = findCliWorkflowStart(workflowData.nodes);
const runData: IWorkflowExecutionDataProcess = { const runData: IWorkflowExecutionDataProcess = {
executionMode: 'cli', executionMode: 'cli',
startNodes: [startNode!.name], startNodes: [startingNode.name],
workflowData, workflowData,
userId: ExecuteBatch.instanceOwner.id, userId: ExecuteBatch.instanceOwner.id,
}; };

View file

@ -33,6 +33,7 @@ import {
IWorkflowHooksOptionalParameters, IWorkflowHooksOptionalParameters,
IWorkflowSettings, IWorkflowSettings,
LoggerProxy as Logger, LoggerProxy as Logger,
SubworkflowOperationError,
Workflow, Workflow,
WorkflowExecuteMode, WorkflowExecuteMode,
WorkflowHooks, WorkflowHooks,
@ -67,6 +68,7 @@ import {
} from './UserManagement/UserManagementHelper'; } from './UserManagement/UserManagementHelper';
import { whereClause } from './WorkflowHelpers'; import { whereClause } from './WorkflowHelpers';
import { IWorkflowErrorData } from './Interfaces'; import { IWorkflowErrorData } from './Interfaces';
import { findSubworkflowStart } from './utils';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -748,21 +750,7 @@ export async function getRunData(
): Promise<IWorkflowExecutionDataProcess> { ): Promise<IWorkflowExecutionDataProcess> {
const mode = 'integrated'; const mode = 'integrated';
// Find Start-Node const startingNode = findSubworkflowStart(workflowData.nodes);
const requiredNodeTypes = ['n8n-nodes-base.start'];
let startNode: INode | undefined;
// eslint-disable-next-line no-restricted-syntax
for (const node of workflowData.nodes) {
if (requiredNodeTypes.includes(node.type)) {
startNode = node;
break;
}
}
if (startNode === undefined) {
// If the workflow does not contain a start-node we can not know what
// should be executed and with what data to start.
throw new Error(`The workflow does not contain a "Start" node and can so not be executed.`);
}
// Always start with empty data if no inputData got supplied // Always start with empty data if no inputData got supplied
inputData = inputData || [ inputData = inputData || [
@ -774,7 +762,7 @@ export async function getRunData(
// Initialize the incoming data // Initialize the incoming data
const nodeExecutionStack: IExecuteData[] = []; const nodeExecutionStack: IExecuteData[] = [];
nodeExecutionStack.push({ nodeExecutionStack.push({
node: startNode, node: startingNode,
data: { data: {
main: [inputData], main: [inputData],
}, },

View file

@ -361,11 +361,12 @@ export class WorkflowRunnerProcess {
) { ) {
// Execute all nodes // Execute all nodes
const pinDataKeys = this.data?.pinData ? Object.keys(this.data.pinData) : [];
const noPinData = pinDataKeys.length === 0;
const isPinned = (nodeName: string) => pinDataKeys.includes(nodeName);
let startNode; let startNode;
if ( if (this.data.startNodes?.length === 1 && (noPinData || isPinned(this.data.startNodes[0]))) {
this.data.startNodes?.length === 1 &&
Object.keys(this.data.pinData ?? {}).includes(this.data.startNodes[0])
) {
startNode = this.workflow.getNode(this.data.startNodes[0]) ?? undefined; startNode = this.workflow.getNode(this.data.startNodes[0]) ?? undefined;
} }

30
packages/cli/src/utils.ts Normal file
View file

@ -0,0 +1,30 @@
import { CliWorkflowOperationError, SubworkflowOperationError } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
function findWorkflowStart(executionMode: 'integrated' | 'cli') {
return function (nodes: INode[]) {
const executeWorkflowTriggerNode = nodes.find(
(node) => node.type === 'n8n-nodes-base.executeWorkflowTrigger',
);
if (executeWorkflowTriggerNode) return executeWorkflowTriggerNode;
const startNode = nodes.find((node) => node.type === 'n8n-nodes-base.start');
if (startNode) return startNode;
const title = 'Missing node to start execution';
const description =
"Please make sure the workflow you're calling contains an Execute Workflow Trigger node";
if (executionMode === 'integrated') {
throw new SubworkflowOperationError(title, description);
}
throw new CliWorkflowOperationError(title, description);
};
}
export const findSubworkflowStart = findWorkflowStart('integrated');
export const findCliWorkflowStart = findWorkflowStart('cli');

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 356 KiB

View file

@ -269,6 +269,11 @@ export interface IWorkflowTemplate {
}; };
} }
export interface INewWorkflowData {
name: string;
onboardingFlowEnabled: boolean;
}
// Almost identical to cli.Interfaces.ts // Almost identical to cli.Interfaces.ts
export interface IWorkflowDb { export interface IWorkflowDb {
id: string; id: string;
@ -756,6 +761,13 @@ export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR';
export interface ISubcategoryItemProps { export interface ISubcategoryItemProps {
subcategory: string; subcategory: string;
description: string; description: string;
icon?: string;
defaults?: INodeParameters;
iconData?: {
type: string;
icon?: string;
fileBuffer?: string;
};
} }
export interface INodeItemProps { export interface INodeItemProps {
@ -876,6 +888,7 @@ export interface IRootState {
instanceId: string; instanceId: string;
nodeMetadata: {[nodeName: string]: INodeMetadata}; nodeMetadata: {[nodeName: string]: INodeMetadata};
isNpmAvailable: boolean; isNpmAvailable: boolean;
subworkflowExecutionError: Error | null;
} }
export interface ICommunityPackageMap { export interface ICommunityPackageMap {
@ -981,6 +994,15 @@ export type IFakeDoor = {
export type IFakeDoorLocation = 'settings' | 'credentialsModal'; export type IFakeDoorLocation = 'settings' | 'credentialsModal';
export type INodeFilterType = "Regular" | "Trigger" | "All";
export interface INodeCreatorState {
itemsFilter: string;
showTabs: boolean;
showScrim: boolean;
selectedType: INodeFilterType;
}
export interface ISettingsState { export interface ISettingsState {
settings: IN8nUISettings; settings: IN8nUISettings;
promptsData: IN8nPrompts; promptsData: IN8nPrompts;

View file

@ -56,7 +56,7 @@ export default mixins(
<style lang="scss"> <style lang="scss">
.main-header { .main-header {
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);
height: 65px; height: $header-height;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
border-bottom: var(--border-width-base) var(--border-style-base) var(--color-foreground-base); border-bottom: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);

View file

@ -66,6 +66,7 @@
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId"/> <WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId"/>
</span> </span>
<SaveButton <SaveButton
type="secondary"
:saved="!this.isDirty && !this.isNewWorkflow" :saved="!this.isDirty && !this.isNewWorkflow"
:disabled="isWorkflowSaving" :disabled="isWorkflowSaving"
@click="onSaveButtonClick" @click="onSaveButtonClick"

View file

@ -2,7 +2,7 @@
<div class="node-wrapper" :style="nodePosition" :id="nodeId"> <div class="node-wrapper" :style="nodePosition" :id="nodeId">
<div class="select-background" v-show="isSelected"></div> <div class="select-background" v-show="isSelected"></div>
<div :class="{'node-default': true, 'touch-active': isTouchActive, 'is-touch-device': isTouchDevice}" :data-name="data.name" :ref="data.name"> <div :class="{'node-default': true, 'touch-active': isTouchActive, 'is-touch-device': isTouchDevice}" :data-name="data.name" :ref="data.name">
<div :class="nodeClass" :style="nodeStyle" @dblclick="setNodeActive" @click.left="mouseLeftClick" v-touch:start="touchStart" v-touch:end="touchEnd"> <div :class="nodeClass" :style="nodeStyle" @dblclick="setNodeActive" @click.left="mouseLeftClick" v-touch:start="touchStart" v-touch:end="touchEnd">
<div v-if="!data.disabled" :class="{'node-info-icon': true, 'shift-icon': shiftOutputCount}"> <div v-if="!data.disabled" :class="{'node-info-icon': true, 'shift-icon': shiftOutputCount}">
<div v-if="hasIssues" class="node-issues"> <div v-if="hasIssues" class="node-issues">
<n8n-tooltip placement="bottom" > <n8n-tooltip placement="bottom" >
@ -60,7 +60,7 @@
<div v-touch:tap="disableNode" class="option" :title="$locale.baseText('node.activateDeactivateNode')"> <div v-touch:tap="disableNode" class="option" :title="$locale.baseText('node.activateDeactivateNode')">
<font-awesome-icon :icon="nodeDisabledIcon" /> <font-awesome-icon :icon="nodeDisabledIcon" />
</div> </div>
<div v-touch:tap="duplicateNode" class="option" :title="$locale.baseText('node.duplicateNode')"> <div v-touch:tap="duplicateNode" class="option" :title="$locale.baseText('node.duplicateNode')" v-if="isDuplicatable">
<font-awesome-icon icon="clone" /> <font-awesome-icon icon="clone" />
</div> </div>
<div v-touch:tap="setNodeActive" class="option touch" :title="$locale.baseText('node.editNode')" v-if="!isReadOnly"> <div v-touch:tap="setNodeActive" class="option touch" :title="$locale.baseText('node.editNode')" v-if="!isReadOnly">
@ -91,7 +91,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import {CUSTOM_API_CALL_KEY, LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, WAIT_TIME_UNLIMITED} from '@/constants'; import { CUSTOM_API_CALL_KEY, LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, WAIT_TIME_UNLIMITED, MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeBase } from '@/components/mixins/nodeBase'; import { nodeBase } from '@/components/mixins/nodeBase';
import { nodeHelpers } from '@/components/mixins/nodeHelpers'; import { nodeHelpers } from '@/components/mixins/nodeHelpers';
@ -126,6 +126,10 @@ export default mixins(
NodeIcon, NodeIcon,
}, },
computed: { computed: {
isDuplicatable(): boolean {
if(!this.nodeType) return true;
return this.nodeType.maxNodes === undefined || this.sameTypeNodes.length < this.nodeType.maxNodes;
},
isScheduledGroup (): boolean { isScheduledGroup (): boolean {
return this.nodeType?.group.includes('schedule') === true; return this.nodeType?.group.includes('schedule') === true;
}, },
@ -183,8 +187,11 @@ export default mixins(
return nodes.length === 1; return nodes.length === 1;
}, },
isManualTypeNode (): boolean {
return this.data.type === MANUAL_TRIGGER_NODE_TYPE;
},
isTriggerNode (): boolean { isTriggerNode (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('trigger')); return this.$store.getters['nodeTypes/isTriggerNode'](this.data.type);
}, },
isTriggerNodeTooltipEmpty () : boolean { isTriggerNodeTooltipEmpty () : boolean {
return this.nodeType !== null ? this.nodeType.eventTriggerDescription === '' : false; return this.nodeType !== null ? this.nodeType.eventTriggerDescription === '' : false;
@ -198,6 +205,9 @@ export default mixins(
node (): INodeUi | undefined { // same as this.data but reactive.. node (): INodeUi | undefined { // same as this.data but reactive..
return this.$store.getters.nodesByName[this.name] as INodeUi | undefined; return this.$store.getters.nodesByName[this.name] as INodeUi | undefined;
}, },
sameTypeNodes (): INodeUi[] {
return this.$store.getters.allNodes.filter((node: INodeUi) => node.type === this.data.type);
},
nodeClass (): object { nodeClass (): object {
return { return {
'node-box': true, 'node-box': true,
@ -378,7 +388,7 @@ export default mixins(
}, },
methods: { methods: {
showPinDataDiscoveryTooltip(dataItemsCount: number): void { showPinDataDiscoveryTooltip(dataItemsCount: number): void {
if (!this.isTriggerNode || this.isScheduledGroup || dataItemsCount === 0) return; if (!this.isTriggerNode || this.isManualTypeNode || this.isScheduledGroup || dataItemsCount === 0) return;
localStorage.setItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, 'true'); localStorage.setItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, 'true');

View file

@ -2,13 +2,17 @@
<div> <div>
<div v-if="!createNodeActive" :class="[$style.nodeButtonsWrapper, showStickyButton ? $style.noEvents : '']" @mouseenter="onCreateMenuHoverIn"> <div v-if="!createNodeActive" :class="[$style.nodeButtonsWrapper, showStickyButton ? $style.noEvents : '']" @mouseenter="onCreateMenuHoverIn">
<div :class="$style.nodeCreatorButton"> <div :class="$style.nodeCreatorButton">
<n8n-icon-button size="xlarge" icon="plus" @click="openNodeCreator" :title="$locale.baseText('nodeView.addNode')"/> <n8n-icon-button size="xlarge" icon="plus" type="tertiary" :class="$style.nodeCreatorPlus" @click="openNodeCreator" :title="$locale.baseText('nodeView.addNode')"/>
<div :class="[$style.addStickyButton, showStickyButton ? $style.visibleButton : '']" @click="addStickyNote"> <div :class="[$style.addStickyButton, showStickyButton ? $style.visibleButton : '']" @click="addStickyNote">
<n8n-icon-button size="medium" type="secondary" :icon="['far', 'note-sticky']" :title="$locale.baseText('nodeView.addSticky')"/> <n8n-icon-button size="medium" type="tertiary" :icon="['far', 'note-sticky']" :title="$locale.baseText('nodeView.addSticky')"/>
</div> </div>
</div> </div>
</div> </div>
<node-creator :active="createNodeActive" @nodeTypeSelected="nodeTypeSelected" @closeNodeCreator="closeNodeCreator" /> <node-creator
:active="createNodeActive"
@nodeTypeSelected="nodeTypeSelected"
@closeNodeCreator="closeNodeCreator"
/>
</div> </div>
</template> </template>
@ -120,12 +124,25 @@ export default Vue.extend({
.nodeCreatorButton { .nodeCreatorButton {
position: fixed; position: fixed;
text-align: center; text-align: center;
top: 80px; top: calc(#{$header-height} + var(--spacing-s));
right: 20px; right: var(--spacing-s);
pointer-events: all !important; pointer-events: all !important;
button { button {
position: relative; border-color: var(--color-foreground-xdark);
color: var(--color-foreground-xdark);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-background-xlight);
}
} }
} }
.nodeCreatorPlus {
border-width: 2px;
border-radius: var(--border-radius-base);
width: 36px;
height: 36px;
}
</style> </style>

View file

@ -0,0 +1,526 @@
<template>
<transition :name="activeSubcategoryTitle ? 'panel-slide-in' : 'panel-slide-out'" >
<div
:class="$style.categorizedItems"
ref="mainPanelContainer"
@click="onClickInside"
tabindex="0"
@keydown.capture="nodeFilterKeyDown"
:key="`${activeSubcategoryTitle}_transition`"
>
<div class="header" v-if="$slots.header">
<slot name="header" />
</div>
<div :class="$style.subcategoryHeader" v-if="activeSubcategory">
<button :class="$style.subcategoryBackButton" @click="onSubcategoryClose">
<font-awesome-icon :class="$style.subcategoryBackIcon" icon="arrow-left" size="2x" />
</button>
<span v-text="activeSubcategoryTitle" />
</div>
<search-bar
v-if="isSearchVisible"
:value="nodeFilter"
@input="onNodeFilterChange"
:eventBus="searchEventBus"
/>
<div v-if="searchFilter.length === 0" :class="$style.scrollable">
<item-iterator
:elements="renderedItems"
:activeIndex="activeSubcategory ? activeSubcategoryIndex : activeIndex"
:transitionsEnabled="true"
@selected="selected"
/>
</div>
<div
:class="$style.scrollable"
v-else-if="filteredNodeTypes.length > 0"
>
<item-iterator
:elements="filteredNodeTypes"
:activeIndex="activeSubcategory ? activeSubcategoryIndex : activeIndex"
@selected="selected"
/>
</div>
<no-results v-else :showRequest="filteredAllNodeTypes.length === 0" :show-icon="filteredAllNodeTypes.length === 0">
<!-- There are results in other sub-categories/tabs -->
<template v-if="filteredAllNodeTypes.length > 0">
<p
v-html="$locale.baseText('nodeCreator.noResults.clickToSeeResults')"
slot="title"
/>
</template>
<!-- Regular Search -->
<template v-else>
<p v-text="$locale.baseText('nodeCreator.noResults.weDidntMakeThatYet')" slot="title" />
<template slot="action">
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
<n8n-link @click="selectHttpRequest" v-if="[REGULAR_NODE_FILTER, ALL_NODE_FILTER].includes(selectedType)">
{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}
</n8n-link>
<template v-if="selectedType === ALL_NODE_FILTER">
{{ $locale.baseText('nodeCreator.noResults.or') }}
</template>
<n8n-link @click="selectWebhook" v-if="[TRIGGER_NODE_FILTER, ALL_NODE_FILTER].includes(selectedType)">
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
</n8n-link>
{{ $locale.baseText('nodeCreator.noResults.node') }}
</template>
</template>
</no-results>
</div>
</transition>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
import camelcase from 'lodash.camelcase';
import { externalHooks } from '@/components/mixins/externalHooks';
import { globalLinkActions } from '@/components/mixins/globalLinkActions';
import mixins from 'vue-typed-mixins';
import ItemIterator from './ItemIterator.vue';
import NoResults from './NoResults.vue';
import SearchBar from './SearchBar.vue';
import { INodeCreateElement, INodeItemProps, ISubcategoryItemProps, ICategoriesWithNodes, ICategoryItemProps, INodeFilterType } from '@/Interface';
import { CORE_NODES_CATEGORY, WEBHOOK_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, ALL_NODE_FILTER, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER, NODE_TYPE_COUNT_MAPPER } from '@/constants';
import { matchesNodeType, matchesSelectType } from './helpers';
import { BaseTextKey } from '@/plugins/i18n';
export default mixins(externalHooks, globalLinkActions).extend({
name: 'CategorizedItems',
components: {
ItemIterator,
NoResults,
SearchBar,
},
props: {
searchItems: {
type: Array as PropType<INodeCreateElement[]>,
},
excludedCategories: {
type: Array as PropType<string[]>,
default: () => [],
},
excludedSubcategories: {
type: Array as PropType<string[]>,
default: () => [],
},
firstLevelItems: {
type: Array as PropType<INodeCreateElement[]>,
default: () => [],
},
initialActiveCategories: {
type: Array as PropType<string[]>,
default: () => [],
},
initialActiveIndex: {
type: Number,
default: 1,
},
},
data() {
return {
activeCategory: this.initialActiveCategories || [] as string[],
// Keep track of activated subcategories so we could traverse back more than one level
activeSubcategoryHistory: [] as INodeCreateElement[],
activeIndex: this.initialActiveIndex,
activeSubcategoryIndex: 0,
searchEventBus: new Vue(),
ALL_NODE_FILTER,
TRIGGER_NODE_FILTER,
REGULAR_NODE_FILTER,
};
},
mounted() {
this.registerCustomAction('showAllNodeCreatorNodes', this.switchToAllTabAndFilter);
},
destroyed() {
this.$store.commit('nodeCreator/setFilter', '');
this.unregisterCustomAction('showAllNodeCreatorNodes');
},
computed: {
activeSubcategory(): INodeCreateElement | null {
return this.activeSubcategoryHistory[this.activeSubcategoryHistory.length - 1] || null;
},
nodeFilter(): string {
return this.$store.getters['nodeCreator/itemsFilter'];
},
selectedType(): INodeFilterType {
return this.$store.getters['nodeCreator/selectedType'];
},
categoriesWithNodes(): ICategoriesWithNodes {
return this.$store.getters['nodeTypes/categoriesWithNodes'];
},
categorizedItems(): INodeCreateElement[] {
return this.$store.getters['nodeTypes/categorizedItems'];
},
activeSubcategoryTitle(): string {
if(!this.activeSubcategory || !this.activeSubcategory.properties) return '';
const subcategoryName = camelcase((this.activeSubcategory.properties as ISubcategoryItemProps).subcategory);
const titleLocaleKey = `nodeCreator.subcategoryTitles.${subcategoryName}` as BaseTextKey;
const nameLocaleKey = `nodeCreator.subcategoryNames.${subcategoryName}` as BaseTextKey;
const titleLocale = this.$locale.baseText(titleLocaleKey);
const nameLocale = this.$locale.baseText(nameLocaleKey);
// If resolved title locale is same as the locale key it means it doesn't exist
// so we fallback to the subcategoryName
return titleLocale === titleLocaleKey ? nameLocale : titleLocale;
},
searchFilter(): string {
return this.nodeFilter.toLowerCase().trim();
},
filteredNodeTypes(): INodeCreateElement[] {
const searchableNodes = this.subcategorizedNodes.length > 0 ? this.subcategorizedNodes : this.searchItems;
const filter = this.searchFilter;
const matchedCategorizedNodes = searchableNodes.filter((el: INodeCreateElement) => {
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);
});
setTimeout(() => {
this.$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', {
nodeFilter: this.nodeFilter,
result: matchedCategorizedNodes,
selectedType: this.selectedType,
});
}, 0);
return matchedCategorizedNodes;
},
filteredAllNodeTypes(): INodeCreateElement[] {
if(this.filteredNodeTypes.length > 0) return [];
const matchedAllNodex = this.searchItems.filter((el: INodeCreateElement) => {
return this.searchFilter && matchesNodeType(el, this.searchFilter);
});
return matchedAllNodex;
},
categorized(): INodeCreateElement[] {
return this.categorizedItems && this.categorizedItems
.reduce((accu: INodeCreateElement[], el: INodeCreateElement) => {
if((this.excludedCategories || []).includes(el.category)) return accu;
if(
el.type === 'subcategory' &&
(this.excludedSubcategories || []).includes((el.properties as ISubcategoryItemProps).subcategory)
) {
return accu;
}
if (
el.type !== 'category' &&
!this.activeCategory.includes(el.category)
) {
return accu;
}
if (!matchesSelectType(el, this.selectedType)) {
return accu;
}
if (el.type === 'category') {
accu.push({
...el,
properties: {
expanded: this.activeCategory.includes(el.category),
},
} as INodeCreateElement);
return accu;
}
accu.push(el);
return accu;
}, []);
},
subcategorizedItems(): INodeCreateElement[] {
const activeSubcategory = this.activeSubcategory;
if(!activeSubcategory) return [];
const category = activeSubcategory.category;
const subcategory = (activeSubcategory.properties as ISubcategoryItemProps).subcategory;
// If no category is set, we use all categorized nodes
const nodes = category
? this.categoriesWithNodes[category][subcategory].nodes
: this.categorized;
return nodes.filter((el: INodeCreateElement) => matchesSelectType(el, this.selectedType));
},
subcategorizedNodes(): INodeCreateElement[] {
return this.subcategorizedItems.filter(node => node.type === 'node');
},
renderedItems(): INodeCreateElement[] {
if(this.firstLevelItems.length > 0 && this.activeSubcategory === null) return this.firstLevelItems;
if(this.subcategorizedItems.length === 0) return this.categorized;
return this.subcategorizedItems;
},
isSearchVisible(): boolean {
if(this.subcategorizedItems.length === 0) return true;
let totalItems = 0;
for (const item of this.subcategorizedItems) {
// Category contains many nodes so we need to count all of them
// for the current selectedType
if(item.type === 'category') {
const categoryItems = this.categoriesWithNodes[item.key];
const categoryItemsCount = Object.values(categoryItems)?.[0];
const countKeys = NODE_TYPE_COUNT_MAPPER[this.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;
},
},
watch: {
isSearchVisible(isVisible) {
if(isVisible === false) {
// Focus the root container when search is hidden to make sure
// keyboard navigation still works
this.$nextTick(() => {
(this.$refs.mainPanelContainer as HTMLElement).focus();
});
}
},
nodeFilter(newValue, oldValue) {
// Reset the index whenver the filter-value changes
this.activeIndex = 0;
this.activeSubcategoryIndex = 0;
this.$externalHooks().run('nodeCreateList.nodeFilterChanged', {
oldValue,
newValue,
selectedType: this.selectedType,
filteredNodes: this.filteredNodeTypes,
});
this.$telemetry.trackNodesPanel('nodeCreateList.nodeFilterChanged', {
oldValue,
newValue,
selectedType: this.selectedType,
filteredNodes: this.filteredNodeTypes,
workflow_id: this.$store.getters.workflowId,
});
},
},
methods: {
switchToAllTabAndFilter() {
const currentFilter = this.nodeFilter;
this.$store.commit('nodeCreator/setShowTabs', true);
this.$store.commit('nodeCreator/setSelectedType', ALL_NODE_FILTER);
this.activeSubcategoryHistory = [];
this.$nextTick(() => this.$store.commit('nodeCreator/setFilter', currentFilter));
},
onNodeFilterChange(filter: string) {
this.$store.commit('nodeCreator/setFilter', filter);
},
selectWebhook() {
this.$emit('nodeTypeSelected', WEBHOOK_NODE_TYPE);
},
selectHttpRequest() {
this.$emit('nodeTypeSelected', HTTP_REQUEST_NODE_TYPE);
},
nodeFilterKeyDown(e: KeyboardEvent) {
// We only want to propagate 'Escape' as it closes the node-creator and
// 'Tab' which toggles it
if (!['Escape', 'Tab'].includes(e.key)) e.stopPropagation();
// Prevent cursors position change
if(['ArrowUp', 'ArrowDown'].includes(e.key)) e.preventDefault();
if (this.activeSubcategory) {
const activeList = this.subcategorizedItems;
const activeNodeType = activeList[this.activeSubcategoryIndex];
if (e.key === 'ArrowDown' && this.activeSubcategory) {
this.activeSubcategoryIndex++;
this.activeSubcategoryIndex = Math.min(
this.activeSubcategoryIndex,
activeList.length - 1,
);
}
else if (e.key === 'ArrowUp' && this.activeSubcategory) {
this.activeSubcategoryIndex--;
this.activeSubcategoryIndex = Math.max(this.activeSubcategoryIndex, 0);
}
else if (e.key === 'Enter') {
this.selected(activeNodeType);
} else if (e.key === 'ArrowLeft' && activeNodeType?.type === 'category' && (activeNodeType.properties as ICategoryItemProps).expanded) {
this.selected(activeNodeType);
} else if (e.key === 'ArrowLeft') {
this.onSubcategoryClose();
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'category' && !(activeNodeType.properties as ICategoryItemProps).expanded) {
this.selected(activeNodeType);
}
return;
}
const activeList = this.searchFilter.length > 0 ? this.filteredNodeTypes : this.renderedItems;
const activeNodeType = activeList[this.activeIndex];
if (e.key === 'ArrowDown') {
this.activeIndex++;
// Make sure that we stop at the last nodeType
this.activeIndex = Math.min(
this.activeIndex,
activeList.length - 1,
);
} else if (e.key === 'ArrowUp') {
this.activeIndex--;
// Make sure that we do not get before the first nodeType
this.activeIndex = Math.max(this.activeIndex, 0);
} else if (e.key === 'Enter' && activeNodeType) {
this.selected(activeNodeType);
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'subcategory') {
this.selected(activeNodeType);
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'category' && !(activeNodeType.properties as ICategoryItemProps).expanded) {
this.selected(activeNodeType);
} else if (e.key === 'ArrowLeft' && activeNodeType?.type === 'category' && (activeNodeType.properties as ICategoryItemProps).expanded) {
this.selected(activeNodeType);
}
},
selected(element: INodeCreateElement) {
const typeHandler = {
node: () => this.$emit('nodeTypeSelected', (element.properties as INodeItemProps).nodeType.name),
category: () => this.onCategorySelected(element.category),
subcategory: () => this.onSubcategorySelected(element),
};
typeHandler[element.type]();
},
onCategorySelected(category: string) {
if (this.activeCategory.includes(category)) {
this.activeCategory = this.activeCategory.filter(
(active: string) => active !== category,
);
} else {
this.activeCategory = [...this.activeCategory, category];
this.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', { category_name: category, workflow_id: this.$store.getters.workflowId });
}
this.activeIndex = this.categorized.findIndex(
(el: INodeCreateElement) => el.category === category,
);
},
onSubcategorySelected(selected: INodeCreateElement) {
this.$emit('onSubcategorySelected', selected);
this.$store.commit('nodeCreator/setShowTabs', false);
this.activeSubcategoryIndex = 0;
this.activeSubcategoryHistory.push(selected);
this.$telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', { selected, workflow_id: this.$store.getters.workflowId });
},
onSubcategoryClose() {
this.$emit('subcategoryClose', this.activeSubcategory);
this.activeSubcategoryHistory.pop();
this.activeSubcategoryIndex = 0;
this.$store.commit('nodeCreator/setFilter', '');
if(!this.$store.getters['nodeCreator/showScrim']) {
this.$store.commit('nodeCreator/setShowTabs', true);
}
},
onClickInside() {
this.searchEventBus.$emit('focus');
},
},
});
</script>
<style lang="scss" module>
:global(.panel-slide-in-leave-active),
:global(.panel-slide-in-enter-active),
:global(.panel-slide-out-leave-active),
:global(.panel-slide-out-enter-active) {
transition: transform 300ms ease;
position: absolute;
left: 0;
right: 0;
}
:global(.panel-slide-out-enter),
:global(.panel-slide-in-leave-to) {
transform: translateX(0);
z-index: -1;
}
:global(.panel-slide-out-leave-to),
:global(.panel-slide-in-enter) {
transform: translateX(100%);
// Make sure the leaving panel stays on top
// for the slide-out panel effect
z-index: 1;
}
.categorizedItems {
background: white;
height: 100%;
background-color: $node-creator-background-color;
&:before {
box-sizing: border-box;
content: '';
border-left: 1px solid $node-creator-border-color;
width: 1px;
position: absolute;
height: 100%;
}
}
.subcategoryHeader {
border-bottom: $node-creator-border-color solid 1px;
height: 50px;
background-color: $node-creator-subcategory-panel-header-bacground-color;
font-size: 18px;
font-weight: 600;
line-height: 16px;
display: flex;
align-items: center;
padding: 11px 15px;
}
.subcategoryBackButton {
background: transparent;
border: none;
cursor: pointer;
padding: var(--spacing-s) 0;
}
.subcategoryBackIcon {
color: $node-creator-arrow-color;
height: 16px;
margin-right: var(--spacing-s);
padding: 0;
}
.scrollable {
height: calc(100% - 120px);
padding-top: 1px;
overflow-y: auto;
overflow-x: visible;
&::-webkit-scrollbar {
display: none;
}
}
</style>

View file

@ -1,7 +1,7 @@
<template> <template>
<div :class="$style.category"> <div :class="$style.category">
<span :class="$style.name"> <span :class="$style.name">
{{ renderCategoryName(categoryName) }} {{ renderCategoryName(categoryName) }} ({{ nodesCount }})
</span> </span>
<font-awesome-icon <font-awesome-icon
:class="$style.arrow" :class="$style.arrow"
@ -13,16 +13,49 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue, { PropType } from 'vue';
import camelcase from 'lodash.camelcase'; import camelcase from 'lodash.camelcase';
import { CategoryName } from '@/plugins/i18n'; import { CategoryName } from '@/plugins/i18n';
import { INodeCreateElement, ICategoriesWithNodes } from '@/Interface';
import { NODE_TYPE_COUNT_MAPPER } from '@/constants';
export default Vue.extend({ export default Vue.extend({
props: ['item'], props: {
item: {
type: Object as PropType<INodeCreateElement>,
},
},
computed: { computed: {
selectedType(): "Regular" | "Trigger" | "All" {
return this.$store.getters['nodeCreator/selectedType'];
},
categoriesWithNodes(): ICategoriesWithNodes {
return this.$store.getters['nodeTypes/categoriesWithNodes'];
},
categorizedItems(): INodeCreateElement[] {
return this.$store.getters['nodeTypes/categorizedItems'];
},
categoryName() { categoryName() {
return camelcase(this.item.category); return camelcase(this.item.category);
}, },
nodesCount(): number {
const currentCategory = this.categoriesWithNodes[this.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[this.selectedType];
for (const countKey of countKeys) {
accu += currentCategory[subcategory][(countKey as "triggerCount" | "regularCount")];
}
return accu;
}, 0);
return count;
},
}, },
methods: { methods: {
renderCategoryName(categoryName: CategoryName) { renderCategoryName(categoryName: CategoryName) {
@ -38,7 +71,7 @@ export default Vue.extend({
<style lang="scss" module> <style lang="scss" module>
.category { .category {
font-size: 11px; font-size: 11px;
font-weight: bold; font-weight: 700;
letter-spacing: 1px; letter-spacing: 1px;
line-height: 11px; line-height: 11px;
padding: 10px 0; padding: 10px 0;
@ -46,6 +79,7 @@ export default Vue.extend({
border-bottom: 1px solid $node-creator-border-color; border-bottom: 1px solid $node-creator-border-color;
display: flex; display: flex;
text-transform: uppercase; text-transform: uppercase;
cursor: pointer;
} }
.name { .name {

View file

@ -7,20 +7,19 @@
}" }"
v-on="$listeners" v-on="$listeners"
> >
<CategoryItem <category-item
v-if="item.type === 'category'" v-if="item.type === 'category'"
:item="item" :item="item"
/> />
<SubcategoryItem <subcategory-item
v-else-if="item.type === 'subcategory'" v-else-if="item.type === 'subcategory'"
:item="item" :item="item"
/> />
<NodeItem <node-item
v-else-if="item.type === 'node'" v-else-if="item.type === 'node'"
:nodeType="item.properties.nodeType" :nodeType="item.properties.nodeType"
:bordered="!lastNode"
@dragstart="$listeners.dragstart" @dragstart="$listeners.dragstart"
@dragend="$listeners.dragend" @dragend="$listeners.dragend"
/> />
@ -28,10 +27,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue, { PropType } from 'vue';
import { INodeCreateElement } from '@/Interface';
import NodeItem from './NodeItem.vue'; import NodeItem from './NodeItem.vue';
import CategoryItem from './CategoryItem.vue';
import SubcategoryItem from './SubcategoryItem.vue'; import SubcategoryItem from './SubcategoryItem.vue';
import CategoryItem from './CategoryItem.vue';
export default Vue.extend({ export default Vue.extend({
name: 'CreatorItem', name: 'CreatorItem',
@ -40,7 +40,20 @@ export default Vue.extend({
SubcategoryItem, SubcategoryItem,
NodeItem, NodeItem,
}, },
props: ['item', 'active', 'clickable', 'lastNode'], props: {
item: {
type: Object as PropType<INodeCreateElement>,
},
active: {
type: Boolean,
},
clickable: {
type: Boolean,
},
lastNode: {
type: Boolean,
},
},
}); });
</script> </script>

View file

@ -1,31 +1,30 @@
<template> <template>
<div> <div
:is="transitionsEnabled ? 'transition-group' : 'div'"
class="item-iterator"
name="accordion"
@before-enter="beforeEnter"
@enter="enter"
@before-leave="beforeLeave"
@leave="leave"
>
<div <div
:is="transitionsEnabled ? 'transition-group' : 'div'" v-for="(item, index) in elements"
name="accordion" :key="item.key"
@before-enter="beforeEnter" :class="item.type"
@enter="enter" :data-key="item.key"
@before-leave="beforeLeave"
@leave="leave"
> >
<div <creator-item
v-for="(item, index) in elements" :item="item"
:key="item.key" :active="activeIndex === index && !disabled"
:class="item.type" :clickable="!disabled"
:data-key="item.key" :lastNode="
> index === elements.length - 1 || elements[index + 1].type !== 'node'
<CreatorItem "
:item="item" @click="$emit('selected', item)"
:active="activeIndex === index && !disabled" @dragstart="emit('dragstart', item, $event)"
:clickable="!disabled" @dragend="emit('dragend', item, $event)"
:lastNode=" />
index === elements.length - 1 || elements[index + 1].type !== 'node'
"
@click="$emit('selected', item)"
@dragstart="emit('dragstart', item, $event)"
@dragend="emit('dragend', item, $event)"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -33,7 +32,7 @@
<script lang="ts"> <script lang="ts">
import { INodeCreateElement } from '@/Interface'; import { INodeCreateElement } from '@/Interface';
import Vue from 'vue'; import Vue, { PropType } from 'vue';
import CreatorItem from './CreatorItem.vue'; import CreatorItem from './CreatorItem.vue';
export default Vue.extend({ export default Vue.extend({
@ -41,7 +40,20 @@ export default Vue.extend({
components: { components: {
CreatorItem, CreatorItem,
}, },
props: ['elements', 'activeIndex', 'disabled', 'transitionsEnabled'], props: {
elements: {
type: Array as PropType<INodeCreateElement[]>,
},
activeIndex: {
type: Number,
},
disabled: {
type: Boolean,
},
transitionsEnabled: {
type: Boolean,
},
},
methods: { methods: {
emit(eventName: string, element: INodeCreateElement, event: Event) { emit(eventName: string, element: INodeCreateElement, event: Event) {
if (this.$props.disabled) { if (this.$props.disabled) {
@ -68,6 +80,9 @@ export default Vue.extend({
<style lang="scss" scoped> <style lang="scss" scoped>
.item-iterator > *:last-child {
margin-bottom: var(--spacing-2xl);
}
.accordion-enter { .accordion-enter {
opacity: 0; opacity: 0;
} }

View file

@ -2,176 +2,64 @@
<div <div
class="container" class="container"
ref="mainPanelContainer" ref="mainPanelContainer"
@click="onClickInside"
> >
<SlideTransition>
<SubcategoryPanel
v-if="activeSubcategory"
:elements="subcategorizedNodes"
:title="activeSubcategory.properties.subcategory"
:activeIndex="activeSubcategoryIndex"
@close="onSubcategoryClose"
@selected="selected"
/>
</SlideTransition>
<div class="main-panel"> <div class="main-panel">
<SearchBar <trigger-helper-panel
v-model="nodeFilter" v-if="selectedType === TRIGGER_NODE_FILTER"
:eventBus="searchEventBus" :searchItems="searchItems"
@keydown.native="nodeFilterKeyDown" @nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
/>
<div class="type-selector">
<el-tabs v-model="selectedType" stretch>
<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>
<div v-if="searchFilter.length === 0" class="scrollable">
<ItemIterator
:elements="categorized"
:disabled="!!activeSubcategory"
:activeIndex="activeIndex"
:transitionsEnabled="true"
@selected="selected"
/>
</div>
<div
class="scrollable"
v-else-if="filteredNodeTypes.length > 0"
> >
<ItemIterator <type-selector slot="header" />
:elements="filteredNodeTypes" </trigger-helper-panel>
:activeIndex="activeIndex" <categorized-items
@selected="selected"
/>
</div>
<NoResults
v-else v-else
@nodeTypeSelected="$emit('nodeTypeSelected', $event)" :searchItems="searchItems"
/> :excludedSubcategories="[OTHER_TRIGGER_NODES_SUBCATEGORY]"
:initialActiveCategories="[CORE_NODES_CATEGORY]"
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
>
<type-selector slot="header" />
</categorized-items>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { PropType } from 'vue';
import Vue from 'vue';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import ItemIterator from './ItemIterator.vue'; import TriggerHelperPanel from './TriggerHelperPanel.vue';
import NoResults from './NoResults.vue'; import { ALL_NODE_FILTER, TRIGGER_NODE_FILTER, OTHER_TRIGGER_NODES_SUBCATEGORY, CORE_NODES_CATEGORY } from '@/constants';
import SearchBar from './SearchBar.vue'; import CategorizedItems from './CategorizedItems.vue';
import SubcategoryPanel from './SubcategoryPanel.vue'; import TypeSelector from './TypeSelector.vue';
import { INodeCreateElement, INodeItemProps, ISubcategoryItemProps } from '@/Interface'; import { INodeCreateElement } from '@/Interface';
import { ALL_NODE_FILTER, CORE_NODES_CATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER } from '@/constants';
import SlideTransition from '../../transitions/SlideTransition.vue';
import { matchesNodeType, matchesSelectType } from './helpers';
export default mixins(externalHooks).extend({ export default mixins(externalHooks).extend({
name: 'NodeCreateList', name: 'NodeCreateList',
components: { components: {
ItemIterator, TriggerHelperPanel,
NoResults, CategorizedItems,
SubcategoryPanel, TypeSelector,
SlideTransition, },
SearchBar, props: {
searchItems: {
type: Array as PropType<INodeCreateElement[] | null>,
},
}, },
props: ['categorizedItems', 'categoriesWithNodes', 'searchItems'],
data() { data() {
return { return {
activeCategory: [] as string[], CORE_NODES_CATEGORY,
activeSubcategory: null as INodeCreateElement | null,
activeIndex: 1,
activeSubcategoryIndex: 0,
nodeFilter: '',
selectedType: ALL_NODE_FILTER,
searchEventBus: new Vue(),
REGULAR_NODE_FILTER,
TRIGGER_NODE_FILTER, TRIGGER_NODE_FILTER,
ALL_NODE_FILTER, ALL_NODE_FILTER,
OTHER_TRIGGER_NODES_SUBCATEGORY,
}; };
}, },
computed: { computed: {
searchFilter(): string { selectedType(): string {
return this.nodeFilter.toLowerCase().trim(); return this.$store.getters['nodeCreator/selectedType'];
},
filteredNodeTypes(): INodeCreateElement[] {
const nodeTypes: INodeCreateElement[] = this.searchItems;
const filter = this.searchFilter;
const returnData = nodeTypes.filter((el: INodeCreateElement) => {
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);
});
setTimeout(() => {
this.$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', {
nodeFilter: this.nodeFilter,
result: returnData,
selectedType: this.selectedType,
});
}, 0);
return returnData;
},
categorized() {
return this.categorizedItems && this.categorizedItems
.reduce((accu: INodeCreateElement[], el: INodeCreateElement) => {
if (
el.type !== 'category' &&
!this.activeCategory.includes(el.category)
) {
return accu;
}
if (!matchesSelectType(el, this.selectedType)) {
return accu;
}
if (el.type === 'category') {
accu.push({
...el,
properties: {
expanded: this.activeCategory.includes(el.category),
},
} as INodeCreateElement);
return accu;
}
accu.push(el);
return accu;
}, []);
},
subcategorizedNodes() {
const activeSubcategory = this.activeSubcategory as INodeCreateElement;
const category = activeSubcategory.category;
const subcategory = (activeSubcategory.properties as ISubcategoryItemProps).subcategory;
return activeSubcategory && this.categoriesWithNodes[category][subcategory]
.nodes.filter((el: INodeCreateElement) => matchesSelectType(el, this.selectedType));
}, },
}, },
watch: { watch: {
nodeFilter(newValue, oldValue) {
// Reset the index whenver the filter-value changes
this.activeIndex = 0;
this.$externalHooks().run('nodeCreateList.nodeFilterChanged', {
oldValue,
newValue,
selectedType: this.selectedType,
filteredNodes: this.filteredNodeTypes,
});
this.$telemetry.trackNodesPanel('nodeCreateList.nodeFilterChanged', {
oldValue,
newValue,
selectedType: this.selectedType,
filteredNodes: this.filteredNodeTypes,
workflow_id: this.$store.getters.workflowId,
});
},
selectedType(newValue, oldValue) { selectedType(newValue, oldValue) {
this.$externalHooks().run('nodeCreateList.selectedTypeChanged', { this.$externalHooks().run('nodeCreateList.selectedTypeChanged', {
oldValue, oldValue,
@ -184,170 +72,23 @@ export default mixins(externalHooks).extend({
}); });
}, },
}, },
methods: { mounted() {
nodeFilterKeyDown(e: KeyboardEvent) {
if (!['Escape', 'Tab'].includes(e.key)) {
// We only want to propagate 'Escape' as it closes the node-creator and
// 'Tab' which toggles it
e.stopPropagation();
}
if (this.activeSubcategory) {
const activeList = this.subcategorizedNodes;
const activeNodeType = activeList[this.activeSubcategoryIndex];
if (e.key === 'ArrowDown' && this.activeSubcategory) {
this.activeSubcategoryIndex++;
this.activeSubcategoryIndex = Math.min(
this.activeSubcategoryIndex,
activeList.length - 1,
);
}
else if (e.key === 'ArrowUp' && this.activeSubcategory) {
this.activeSubcategoryIndex--;
this.activeSubcategoryIndex = Math.max(this.activeSubcategoryIndex, 0);
}
else if (e.key === 'Enter') {
this.selected(activeNodeType);
}
else if (e.key === 'ArrowLeft') {
this.onSubcategoryClose();
}
return;
}
let activeList;
if (this.searchFilter.length > 0) {
activeList = this.filteredNodeTypes;
} else {
activeList = this.categorized;
}
const activeNodeType = activeList[this.activeIndex];
if (e.key === 'ArrowDown') {
this.activeIndex++;
// Make sure that we stop at the last nodeType
this.activeIndex = Math.min(
this.activeIndex,
activeList.length - 1,
);
} else if (e.key === 'ArrowUp') {
this.activeIndex--;
// Make sure that we do not get before the first nodeType
this.activeIndex = Math.max(this.activeIndex, 0);
} else if (e.key === 'Enter' && activeNodeType) {
this.selected(activeNodeType);
} else if (e.key === 'ArrowRight' && activeNodeType && activeNodeType.type === 'subcategory') {
this.selected(activeNodeType);
} else if (e.key === 'ArrowRight' && activeNodeType && activeNodeType.type === 'category' && !activeNodeType.properties.expanded) {
this.selected(activeNodeType);
} else if (e.key === 'ArrowLeft' && activeNodeType && activeNodeType.type === 'category' && activeNodeType.properties.expanded) {
this.selected(activeNodeType);
}
},
selected(element: INodeCreateElement) {
if (element.type === 'node') {
this.$emit('nodeTypeSelected', (element.properties as INodeItemProps).nodeType.name);
} else if (element.type === 'category') {
this.onCategorySelected(element.category);
} else if (element.type === 'subcategory') {
this.onSubcategorySelected(element);
}
},
onCategorySelected(category: string) {
if (this.activeCategory.includes(category)) {
this.activeCategory = this.activeCategory.filter(
(active: string) => active !== category,
);
} else {
this.activeCategory = [...this.activeCategory, category];
this.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', { category_name: category, workflow_id: this.$store.getters.workflowId });
}
this.activeIndex = this.categorized.findIndex(
(el: INodeCreateElement) => el.category === category,
);
},
onSubcategorySelected(selected: INodeCreateElement) {
this.activeSubcategoryIndex = 0;
this.activeSubcategory = selected;
this.$telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', { selected, workflow_id: this.$store.getters.workflowId });
},
onSubcategoryClose() {
this.activeSubcategory = null;
this.activeSubcategoryIndex = 0;
this.nodeFilter = '';
},
onClickInside() {
this.searchEventBus.$emit('focus');
},
},
async mounted() {
this.$nextTick(() => {
// initial opening effect
this.activeCategory = [CORE_NODES_CATEGORY];
});
this.$externalHooks().run('nodeCreateList.mounted'); this.$externalHooks().run('nodeCreateList.mounted');
// Make sure tabs are visible on mount
this.$store.commit('nodeCreator/setShowTabs', true);
}, },
async destroyed() { destroyed() {
this.$store.commit('nodeCreator/setSelectedType', ALL_NODE_FILTER);
this.$externalHooks().run('nodeCreateList.destroyed'); this.$externalHooks().run('nodeCreateList.destroyed');
this.$telemetry.trackNodesPanel('nodeCreateList.destroyed', { workflow_id: this.$store.getters.workflowId }); this.$telemetry.trackNodesPanel('nodeCreateList.destroyed', { workflow_id: this.$store.getters.workflowId });
}, },
}); });
</script> </script>
<style lang="scss" scoped> <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;
}
.container { .container {
height: 100%; height: 100%;
> div {
height: 100%;
}
} }
.main-panel {
.main-panel .scrollable { height: 100%;
height: calc(100% - 160px);
padding-top: 1px;
}
.scrollable {
overflow-y: auto;
overflow-x: visible;
&::-webkit-scrollbar {
display: none;
}
> div {
padding-bottom: 30px;
}
}
.type-selector {
text-align: center;
background-color: $node-creator-select-background-color;
::v-deep .el-tabs > div {
margin-bottom: 0;
.el-tabs__nav {
height: 43px;
}
}
} }
</style> </style>

View file

@ -1,31 +1,23 @@
<template> <template>
<div class="no-results"> <div :class="$style.noResults">
<div class="icon"> <div :class="$style.icon" v-if="showIcon">
<NoResultsIcon /> <no-results-icon />
</div> </div>
<div class="title"> <div :class="$style.title">
<div> <slot name="title" />
{{ $locale.baseText('nodeCreator.noResults.weDidntMakeThatYet') }} <div :class="$style.action">
</div> <slot name="action" />
<div class="action">
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
<n8n-link @click="selectHttpRequest">{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}</n8n-link> {{ $locale.baseText('nodeCreator.noResults.or') }}
<n8n-link @click="selectWebhook">{{ $locale.baseText('nodeCreator.noResults.webhook') }}</n8n-link> {{ $locale.baseText('nodeCreator.noResults.node') }}
</div> </div>
</div> </div>
<div class="request"> <div :class="$style.request" v-if="showRequest">
<p v-text="$locale.baseText('nodeCreator.noResults.wantUsToMakeItFaster')" />
<div> <div>
{{ $locale.baseText('nodeCreator.noResults.wantUsToMakeItFaster') }} <n8n-link :to="REQUEST_NODE_FORM_URL">
</div>
<div>
<n8n-link
:to="REQUEST_NODE_FORM_URL"
>
<span>{{ $locale.baseText('nodeCreator.noResults.requestTheNode') }}</span>&nbsp; <span>{{ $locale.baseText('nodeCreator.noResults.requestTheNode') }}</span>&nbsp;
<span> <span>
<font-awesome-icon <font-awesome-icon
class="external" :class="$style.external"
icon="external-link-alt" icon="external-link-alt"
:title="$locale.baseText('nodeCreator.noResults.requestTheNode')" :title="$locale.baseText('nodeCreator.noResults.requestTheNode')"
/> />
@ -38,12 +30,20 @@
<script lang="ts"> <script lang="ts">
import { HTTP_REQUEST_NODE_TYPE, REQUEST_NODE_FORM_URL, WEBHOOK_NODE_TYPE } from '@/constants'; import { REQUEST_NODE_FORM_URL } from '@/constants';
import Vue from 'vue'; import Vue from 'vue';
import NoResultsIcon from './NoResultsIcon.vue'; import NoResultsIcon from './NoResultsIcon.vue';
export default Vue.extend({ export default Vue.extend({
name: 'NoResults', name: 'NoResults',
props: {
showRequest: {
type: Boolean,
},
showIcon: {
type: Boolean,
},
},
components: { components: {
NoResultsIcon, NoResultsIcon,
}, },
@ -52,20 +52,11 @@ export default Vue.extend({
REQUEST_NODE_FORM_URL, REQUEST_NODE_FORM_URL,
}; };
}, },
methods: {
selectWebhook() {
this.$emit('nodeTypeSelected', WEBHOOK_NODE_TYPE);
},
selectHttpRequest() {
this.$emit('nodeTypeSelected', HTTP_REQUEST_NODE_TYPE);
},
},
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.no-results { .noResults {
background-color: $node-creator-no-results-background-color; background-color: $node-creator-no-results-background-color;
text-align: center; text-align: center;
height: 100%; height: 100%;
@ -75,27 +66,27 @@ export default Vue.extend({
display: flex; display: flex;
align-items: center; align-items: center;
align-content: center; align-content: center;
padding: 0 50px; padding: 0 var(--spacing-2xl);
} }
.title { .title {
font-size: 22px; font-size: var(--font-size-m);
line-height: 22px; line-height: var(--font-line-height-regular);
margin-top: 50px; margin-top: var(--spacing-xs);
div { div {
margin-bottom: 15px; margin-bottom: var(--spacing-s);
} }
} }
.action, .request { .action, .request {
font-size: 14px; font-size: var(--font-size-s);
line-height: 19px; line-height: var(--font-line-height-compact);
} }
.request { .request {
position: fixed; position: fixed;
bottom: 20px; bottom: var(--spacing-m);
display: none; display: none;
@media (min-height: 550px) { @media (min-height: 550px) {
@ -104,13 +95,13 @@ export default Vue.extend({
} }
.icon { .icon {
margin-top: 100px; margin-top: var(--spacing-2xl);
min-height: 67px; min-height: 67px;
opacity: .6; opacity: .6;
} }
.external { .external {
font-size: 12px; font-size: var(--font-size-2xs);
} }
</style> </style>

View file

@ -1,34 +1,34 @@
<template> <template>
<SlideTransition> <div>
<div <aside :class="{'node-creator-scrim': true, expanded: !sidebarMenuCollapsed, active: showScrim}" />
v-if="active"
class="node-creator" <slide-transition>
ref="nodeCreator" <div
v-click-outside="onClickOutside" v-if="active"
@dragover="onDragOver" class="node-creator"
@drop="onDrop" ref="nodeCreator"
> v-click-outside="onClickOutside"
<MainPanel @dragover="onDragOver"
@nodeTypeSelected="nodeTypeSelected" @drop="onDrop"
:categorizedItems="categorizedItems" >
:categoriesWithNodes="categoriesWithNodes" <main-panel
:searchItems="searchItems" @nodeTypeSelected="nodeTypeSelected"
/> :searchItems="searchItems"
</div> />
</SlideTransition> </div>
</slide-transition>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { ICategoriesWithNodes, INodeCreateElement } from '@/Interface'; import { INodeCreateElement } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow'; import { INodeTypeDescription } from 'n8n-workflow';
import SlideTransition from '../../transitions/SlideTransition.vue'; import SlideTransition from '../../transitions/SlideTransition.vue';
import MainPanel from './MainPanel.vue'; import MainPanel from './MainPanel.vue';
import { getCategoriesWithNodes, getCategorizedList } from './helpers';
import { mapGetters } from 'vuex';
export default Vue.extend({ export default Vue.extend({
name: 'NodeCreator', name: 'NodeCreator',
@ -40,20 +40,16 @@ export default Vue.extend({
active: { active: {
type: Boolean, type: Boolean,
}, },
}, },
computed: { computed: {
...mapGetters('users', ['personalizedNodeTypes']), showScrim(): boolean {
allLatestNodeTypes(): INodeTypeDescription[] { return this.$store.getters['nodeCreator/showScrim'];
return this.$store.getters['nodeTypes/allLatestNodeTypes']; },
sidebarMenuCollapsed(): boolean {
return this.$store.getters['ui/sidebarMenuCollapsed'];
}, },
visibleNodeTypes(): INodeTypeDescription[] { visibleNodeTypes(): INodeTypeDescription[] {
return this.allLatestNodeTypes.filter((nodeType) => !nodeType.hidden); return this.$store.getters['nodeTypes/visibleNodeTypes'];
},
categoriesWithNodes(): ICategoriesWithNodes {
return getCategoriesWithNodes(this.visibleNodeTypes, this.personalizedNodeTypes as string[]);
},
categorizedItems(): INodeCreateElement[] {
return getCategorizedList(this.categoriesWithNodes);
}, },
searchItems(): INodeCreateElement[] { searchItems(): INodeCreateElement[] {
const sorted = [...this.visibleNodeTypes]; const sorted = [...this.visibleNodeTypes];
@ -102,6 +98,11 @@ export default Vue.extend({
} }
}, },
}, },
watch: {
active(isActive) {
if(isActive === false) this.$store.commit('nodeCreator/setShowScrim', false);
},
},
}); });
</script> </script>
@ -113,20 +114,31 @@ export default Vue.extend({
.node-creator { .node-creator {
position: fixed; position: fixed;
top: $header-height; top: $header-height;
bottom: 0;
right: 0; right: 0;
width: $node-creator-width;
height: 100%;
background-color: $node-creator-background-color;
z-index: 200; z-index: 200;
width: $node-creator-width;
color: $node-creator-text-color; color: $node-creator-text-color;
}
&:before { .node-creator-scrim {
box-sizing: border-box; position: fixed;
content: ' '; top: $header-height;
border-left: 1px solid $node-creator-border-color; right: 0;
width: 1px; bottom: 0;
position: absolute; left: $sidebar-width;
height: 100%; opacity: 0;
z-index: 1;
background: var(--color-background-dark);
pointer-events: none;
transition: opacity 200ms ease-in-out;
&.expanded {
left: $sidebar-expanded-width
}
&.active {
opacity: 0.7;
} }
} }
</style> </style>

View file

@ -3,9 +3,9 @@
draggable draggable
@dragstart="onDragStart" @dragstart="onDragStart"
@dragend="onDragEnd" @dragend="onDragEnd"
:class="{[$style['node-item']]: true, [$style.bordered]: bordered}" :class="{[$style['node-item']]: true}"
> >
<NodeIcon :class="$style['node-icon']" :nodeType="nodeType" /> <node-icon :class="$style['node-icon']" :nodeType="nodeType" />
<div> <div>
<div :class="$style.details"> <div :class="$style.details">
<span :class="$style.name"> <span :class="$style.name">
@ -16,7 +16,7 @@
}} }}
</span> </span>
<span v-if="isTrigger" :class="$style['trigger-icon']"> <span v-if="isTrigger" :class="$style['trigger-icon']">
<TriggerIcon /> <trigger-icon />
</span> </span>
<n8n-tooltip v-if="isCommunityNode" placement="top"> <n8n-tooltip v-if="isCommunityNode" placement="top">
<div <div
@ -45,7 +45,7 @@
ref="draggable" ref="draggable"
v-show="dragging" v-show="dragging"
> >
<NodeIcon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" /> <node-icon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" />
</div> </div>
</transition> </transition>
</div> </div>
@ -54,26 +54,29 @@
<script lang="ts"> <script lang="ts">
import {getNewNodePosition, NODE_SIZE} from '@/views/canvasHelpers'; import Vue, { PropType } from 'vue';
import Vue from 'vue'; import { INodeTypeDescription } from 'n8n-workflow';
import NodeIcon from '../../NodeIcon.vue';
import TriggerIcon from '../../TriggerIcon.vue';
import { getNewNodePosition, NODE_SIZE } from '@/views/canvasHelpers';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants'; import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants';
import { isCommunityPackageName } from '../../helpers';
Vue.component('NodeIcon', NodeIcon); import NodeIcon from '@/components/NodeIcon.vue';
Vue.component('TriggerIcon', TriggerIcon); import TriggerIcon from '@/components/TriggerIcon.vue';
import { isCommunityPackageName } from '@/components/helpers';
Vue.component('node-icon', NodeIcon);
Vue.component('trigger-icon', TriggerIcon);
export default Vue.extend({ export default Vue.extend({
name: 'NodeItem', name: 'NodeItem',
props: [ props: {
'active', nodeType: {
'filter', type: Object as PropType<INodeTypeDescription>,
'nodeType', },
'bordered', active: {
], type: Boolean,
},
},
data() { data() {
return { return {
dragging: false, dragging: false,
@ -160,10 +163,7 @@ export default Vue.extend({
margin-left: 15px; margin-left: 15px;
margin-right: 12px; margin-right: 12px;
display: flex; display: flex;
cursor: grab;
&.bordered {
border-bottom: 1px solid $node-creator-border-color;
}
} }
.details { .details {
@ -177,7 +177,7 @@ export default Vue.extend({
} }
.name { .name {
font-weight: bold; font-weight: var(--font-weight-bold);
font-size: 14px; font-size: 14px;
line-height: 18px; line-height: 18px;
margin-right: 5px; margin-right: 5px;
@ -189,7 +189,7 @@ export default Vue.extend({
.description { .description {
margin-top: 2px; margin-top: 2px;
font-size: 11px; font-size: var(--font-size-2xs);
line-height: 16px; line-height: 16px;
font-weight: 400; font-weight: 400;
color: $node-creator-description-color; color: $node-creator-description-color;

View file

@ -1,40 +1,46 @@
<template> <template>
<div class="search-container"> <div :class="$style.searchContainer">
<div :class="{ prefix: true, active: value.length > 0 }"> <div :class="{ [$style.prefix]: true, [$style.active]: value.length > 0 }">
<font-awesome-icon icon="search" /> <font-awesome-icon icon="search" size="sm" />
</div> </div>
<div class="text"> <div :class="$style.text">
<input <input
:placeholder="$locale.baseText('nodeCreator.searchBar.searchNodes')" :placeholder="$locale.baseText('nodeCreator.searchBar.searchNodes')"
ref="input" ref="input"
:value="value" :value="value"
@input="onInput" @input="onInput"
:class="$style.input"
/> />
</div> </div>
<div class="suffix" v-if="value.length > 0" @click="clear"> <div :class="$style.suffix" v-if="value.length > 0" @click="clear">
<span class="clear el-icon-close clickable"></span> <button :class="[$style.clear, $style.clickable]">
<font-awesome-icon icon="times-circle" />
</button>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue, { PropType } from 'vue';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
export default mixins(externalHooks).extend({ export default mixins(externalHooks).extend({
name: "SearchBar", name: "SearchBar",
props: ["value", "eventBus"], props: {
value: {
type: String,
},
eventBus: {
type: Object as PropType<Vue>,
},
},
mounted() { mounted() {
if (this.$props.eventBus) { if (this.eventBus) {
this.$props.eventBus.$on("focus", () => { this.eventBus.$on("focus", this.focus);
this.focus();
});
} }
setTimeout(() => { setTimeout(this.focus, 0);
this.focus();
}, 0);
this.$externalHooks().run('nodeCreator_searchBar.mount', { inputRef: this.$refs['input'] }); this.$externalHooks().run('nodeCreator_searchBar.mount', { inputRef: this.$refs['input'] });
}, },
@ -53,25 +59,37 @@ export default mixins(externalHooks).extend({
this.$emit("input", ""); this.$emit("input", "");
}, },
}, },
beforeDestroy() {
if (this.eventBus) {
this.eventBus.$off("focus", this.focus);
}
},
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.search-container { .searchContainer {
display: flex; display: flex;
height: 60px; height: 40px;
padding: var(--spacing-s) var(--spacing-xs);
align-items: center; align-items: center;
padding-left: 14px; margin: var(--spacing-s);
padding-right: 20px; filter: drop-shadow(0px 2px 5px rgba(46, 46, 50, 0.04));
border-bottom: 1px solid $node-creator-border-color;
border: 1px solid $node-creator-border-color;
background-color: $node-creator-search-background-color; background-color: $node-creator-search-background-color;
color: $node-creator-search-placeholder-color; color: $node-creator-search-placeholder-color;
border-radius: 4px;
&:focus-within {
border-color: var(--color-secondary)
}
} }
.prefix { .prefix {
text-align: center; text-align: center;
font-size: 16px; font-size: var(--font-size-m);
margin-right: 14px; margin-right: var(--spacing-xs);
&.active { &.active {
color: $color-primary !important; color: $color-primary !important;
@ -83,10 +101,10 @@ export default mixins(externalHooks).extend({
input { input {
width: 100%; width: 100%;
border: none !important; border: none;
outline: none; outline: none;
font-size: 18px; font-size: var(--font-size-s);
-webkit-appearance: none; appearance: none;
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);
color: var(--color-text-dark); color: var(--color-text-dark);
@ -99,32 +117,22 @@ export default mixins(externalHooks).extend({
.suffix { .suffix {
min-width: 20px; min-width: 20px;
text-align: center; text-align: right;
display: inline-block; display: inline-block;
} }
.clear { .clear {
background-color: $node-creator-search-clear-background-color; background-color: $node-creator-search-clear-color;
border-radius: 50%; padding: 0;
height: 16px; border: none;
width: 16px; cursor: pointer;
font-size: 16px;
color: $node-creator-search-background-color;
display: inline-flex;
align-items: center;
&:hover { svg path {
background-color: $node-creator-search-clear-background-color-hover; fill: $node-creator-search-clear-background-color;
} }
&:before { &:hover svg path {
line-height: 16px; fill: $node-creator-search-clear-background-color-hover;
display: flex;
height: 16px;
width: 16px;
font-size: 15px;
align-items: center;
justify-content: center;
} }
} }
</style> </style>

View file

@ -1,5 +1,6 @@
<template> <template>
<div :class="$style.subcategory"> <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.details">
<div :class="$style.title"> <div :class="$style.title">
{{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }} {{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }}
@ -15,14 +16,30 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue, { PropType } from 'vue';
import camelcase from 'lodash.camelcase'; import camelcase from 'lodash.camelcase';
import NodeIcon from '@/components/NodeIcon.vue';
import { INodeCreateElement, ISubcategoryItemProps } from '@/Interface';
export default Vue.extend({ export default Vue.extend({
props: ['item'], components: {
NodeIcon,
},
props: {
item: {
type: Object as PropType<INodeCreateElement>,
required: true,
},
},
computed: { computed: {
subcategoryName() { itemProperties() : ISubcategoryItemProps {
return camelcase(this.item.properties.subcategory); return this.item.properties as ISubcategoryItemProps;
},
subcategoryName(): string {
return camelcase(this.itemProperties.subcategory);
},
hasIcon(): boolean {
return this.itemProperties.icon !== undefined || this.itemProperties.iconData !== undefined;
}, },
}, },
}); });
@ -30,11 +47,23 @@ export default Vue.extend({
<style lang="scss" module> <style lang="scss" module>
.subcategoryIcon {
min-width: 26px;
max-width: 26px;
margin-right: 15px;
}
.subcategory { .subcategory {
display: flex; display: flex;
padding: 11px 16px 11px 30px; padding: 11px 16px 11px 30px;
} }
.subcategoryWithIcon {
margin-left: 15px;
margin-right: 12px;
padding: 11px 8px 11px 0;
}
.details { .details {
flex-grow: 1; flex-grow: 1;
margin-right: 4px; margin-right: 4px;
@ -42,13 +71,13 @@ export default Vue.extend({
.title { .title {
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: var(--font-weight-bold);
line-height: 16px; line-height: 16px;
margin-bottom: 3px; margin-bottom: 3px;
} }
.description { .description {
font-size: 11px; font-size: var(--font-size-2xs);
line-height: 16px; line-height: 16px;
font-weight: 400; font-weight: 400;
color: $node-creator-description-color; color: $node-creator-description-color;
@ -57,6 +86,7 @@ export default Vue.extend({
.action { .action {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: var(--spacing-2xs);
} }
.arrow { .arrow {

View file

@ -1,102 +0,0 @@
<template>
<div class="subcategory-panel">
<div class="subcategory-header">
<div class="clickable" @click="onBackArrowClick">
<font-awesome-icon class="back-arrow" icon="arrow-left" />
</div>
<span>
{{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }}
</span>
</div>
<div class="scrollable">
<ItemIterator
:elements="elements"
:activeIndex="activeIndex"
@selected="$emit('selected', $event)"
@dragstart="$emit('dragstart', $event)"
@dragend="$emit('dragend', $event)"
/>
</div>
</div>
</template>
<script lang="ts">
import camelcase from 'lodash.camelcase';
import Vue from 'vue';
import ItemIterator from './ItemIterator.vue';
export default Vue.extend({
name: 'SubcategoryPanel',
components: {
ItemIterator,
},
props: ['title', 'elements', 'activeIndex'],
computed: {
subcategoryName() {
return camelcase(this.title);
},
},
methods: {
onBackArrowClick() {
this.$emit('close');
},
},
});
</script>
<style lang="scss" scoped>
.subcategory-panel {
position: absolute;
background: $node-creator-search-background-color;
z-index: 100;
height: 100%;
width: 100%;
&:before {
box-sizing: border-box;
content: ' ';
border-left: 1px solid $node-creator-border-color;
width: 1px;
position: absolute;
height: 100%;
}
}
.subcategory-header {
border: $node-creator-border-color solid 1px;
height: 50px;
background-color: $node-creator-subcategory-panel-header-bacground-color;
font-size: 18px;
font-weight: 600;
line-height: 16px;
display: flex;
align-items: center;
padding: 11px 15px;
}
.back-arrow {
color: $node-creator-arrow-color;
height: 16px;
width: 16px;
margin-right: 24px;
}
.scrollable {
overflow-y: auto;
overflow-x: visible;
height: calc(100% - 100px);
&::-webkit-scrollbar {
display: none;
}
> div {
padding-bottom: 30px;
}
}
</style>

View file

@ -0,0 +1,196 @@
<template>
<div :class="{ [$style.triggerHelperContainer]: true, [$style.isRoot]: isRoot }">
<categorized-items
ref="categorizedItems"
@subcategoryClose="onSubcategoryClose"
@onSubcategorySelected="onSubcategorySelected"
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
:initialActiveIndex="0"
:searchItems="searchItems"
:firstLevelItems="isRoot ? items : []"
:excludedCategories="[CORE_NODES_CATEGORY]"
:initialActiveCategories="[COMMUNICATION_CATEGORY]"
>
<template #header>
<slot name="header" />
<p v-if="isRoot" v-text="$locale.baseText('nodeCreator.triggerHelperPanel.title')" :class="$style.title" />
</template>
</categorized-items>
</div>
</template>
<script lang="ts">
import { PropType } from 'vue';
import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/components/mixins/externalHooks';
import { INodeCreateElement } from '@/Interface';
import { CORE_NODES_CATEGORY, CRON_NODE_TYPE, WEBHOOK_NODE_TYPE, OTHER_TRIGGER_NODES_SUBCATEGORY, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, COMMUNICATION_CATEGORY } from '@/constants';
import ItemIterator from './ItemIterator.vue';
import CategorizedItems from './CategorizedItems.vue';
import SearchBar from './SearchBar.vue';
export default mixins(externalHooks).extend({
name: 'TriggerHelperPanel',
components: {
ItemIterator,
CategorizedItems,
SearchBar,
},
props: {
searchItems: {
type: Array as PropType<INodeCreateElement[] | null>,
},
},
data() {
return {
CORE_NODES_CATEGORY,
COMMUNICATION_CATEGORY,
isRoot: true,
};
},
computed: {
items() {
return [{
key: "core_nodes",
type: "subcategory",
title: this.$locale.baseText('nodeCreator.subcategoryNames.appTriggerNodes'),
properties: {
subcategory: "App Trigger Nodes",
description: this.$locale.baseText('nodeCreator.subcategoryDescriptions.appTriggerNodes'),
icon: "fa:satellite-dish",
defaults: {
color: "#7D838F",
},
},
},
{
key: CRON_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: CRON_NODE_TYPE,
displayName: this.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName'),
description: this.$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: this.$locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDisplayName'),
description: this.$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: this.$locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
description: this.$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: this.$locale.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDisplayName'),
description: this.$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: this.$locale.baseText('nodeCreator.subcategoryDescriptions.otherTriggerNodes'),
icon: "fa:folder-open",
defaults: {
color: "#7D838F",
},
},
},
];
},
},
methods: {
isRootSubcategory(subcategory: INodeCreateElement) {
return this.items.find(item => item.key === subcategory.key) !== undefined;
},
onSubcategorySelected() {
this.isRoot = false;
},
onSubcategoryClose(subcategory: INodeCreateElement) {
this.isRoot = this.isRootSubcategory(subcategory);
},
},
});
</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

@ -0,0 +1,64 @@
<template>
<div class="type-selector" v-if="showTabs">
<el-tabs stretch :value="selectedType" @input="setType">
<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 lang="ts">
import { ALL_NODE_FILTER, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER } from '@/constants';
import Vue from 'vue';
export default Vue.extend({
name: 'NodeCreateTypeSelector',
data() {
return {
REGULAR_NODE_FILTER,
TRIGGER_NODE_FILTER,
ALL_NODE_FILTER,
};
},
methods: {
setType(type: string) {
this.$store.commit('nodeCreator/setSelectedType', type);
},
},
computed: {
showTabs(): boolean {
return this.$store.getters['nodeCreator/showTabs'];
},
selectedType(): string {
return this.$store.getters['nodeCreator/selectedType'];
},
},
});
</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;
.el-tabs__nav {
height: 43px;
}
}
}
</style>

View file

@ -1,145 +1,7 @@
import { CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, SUBCATEGORY_DESCRIPTIONS, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER, ALL_NODE_FILTER, PERSONALIZED_CATEGORY } from '@/constants'; import { REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER, ALL_NODE_FILTER } from '@/constants';
import { INodeCreateElement, ICategoriesWithNodes, INodeItemProps } from '@/Interface'; import { INodeCreateElement, INodeItemProps } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow'; import { INodeTypeDescription } from 'n8n-workflow';
const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescription, category: string, subcategory: string) => {
if (!accu[category]) {
accu[category] = {};
}
if (!accu[category][subcategory]) {
accu[category][subcategory] = {
triggerCount: 0,
regularCount: 0,
nodes: [],
};
}
const isTrigger = nodeType.group.includes('trigger');
if (isTrigger) {
accu[category][subcategory].triggerCount++;
}
if (!isTrigger) {
accu[category][subcategory].regularCount++;
}
accu[category][subcategory].nodes.push({
type: 'node',
key: `${category}_${nodeType.name}`,
category,
properties: {
nodeType,
subcategory,
},
includedByTrigger: isTrigger,
includedByRegular: !isTrigger,
});
};
export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[], personalizedNodeTypes: string[]): ICategoriesWithNodes => {
const sorted = [...nodeTypes].sort((a: INodeTypeDescription, b: INodeTypeDescription) => a.displayName > b.displayName? 1 : -1);
return sorted.reduce(
(accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => {
if (personalizedNodeTypes.includes(nodeType.name)) {
addNodeToCategory(accu, nodeType, PERSONALIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
}
if (!nodeType.codex || !nodeType.codex.categories) {
addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
return accu;
}
nodeType.codex.categories.forEach((_category: string) => {
const category = _category.trim();
const subcategory =
nodeType.codex &&
nodeType.codex.subcategories &&
nodeType.codex.subcategories[category]
? nodeType.codex.subcategories[category][0]
: UNCATEGORIZED_SUBCATEGORY;
addNodeToCategory(accu, nodeType, category, subcategory);
});
return accu;
},
{},
);
};
const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
const excludeFromSort = [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, UNCATEGORIZED_CATEGORY, PERSONALIZED_CATEGORY];
const categories = Object.keys(categoriesWithNodes);
const sorted = categories.filter(
(category: string) =>
!excludeFromSort.includes(category),
);
sorted.sort();
return [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, PERSONALIZED_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY];
};
export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): INodeCreateElement[] => {
const categories = getCategories(categoriesWithNodes);
return categories.reduce(
(accu: INodeCreateElement[], category: string) => {
if (!categoriesWithNodes[category]) {
return accu;
}
const categoryEl: INodeCreateElement = {
type: 'category',
key: category,
category,
properties: {
expanded: false,
},
};
const subcategories = Object.keys(categoriesWithNodes[category]);
if (subcategories.length === 1) {
const subcategory = categoriesWithNodes[category][
subcategories[0]
];
if (subcategory.triggerCount > 0) {
categoryEl.includedByTrigger = subcategory.triggerCount > 0;
}
if (subcategory.regularCount > 0) {
categoryEl.includedByRegular = subcategory.regularCount > 0;
}
return [...accu, categoryEl, ...subcategory.nodes];
}
subcategories.sort();
const subcategorized = subcategories.reduce(
(accu: INodeCreateElement[], subcategory: string) => {
const subcategoryEl: INodeCreateElement = {
type: 'subcategory',
key: `${category}_${subcategory}`,
category,
properties: {
subcategory,
description: SUBCATEGORY_DESCRIPTIONS[category][subcategory],
},
includedByTrigger: categoriesWithNodes[category][subcategory].triggerCount > 0,
includedByRegular: categoriesWithNodes[category][subcategory].regularCount > 0,
};
if (subcategoryEl.includedByTrigger) {
categoryEl.includedByTrigger = true;
}
if (subcategoryEl.includedByRegular) {
categoryEl.includedByRegular = true;
}
accu.push(subcategoryEl);
return accu;
},
[],
);
return [...accu, categoryEl, ...subcategorized];
},
[],
);
};
export const matchesSelectType = (el: INodeCreateElement, selectedType: string) => { export const matchesSelectType = (el: INodeCreateElement, selectedType: string) => {
if (selectedType === REGULAR_NODE_FILTER && el.includedByRegular) { if (selectedType === REGULAR_NODE_FILTER && el.includedByRegular) {

View file

@ -16,7 +16,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { WEBHOOK_NODE_TYPE } from '@/constants'; import { WEBHOOK_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
import { INodeUi } from '@/Interface'; import { INodeUi } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow'; import { INodeTypeDescription } from 'n8n-workflow';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
@ -72,7 +72,10 @@ export default mixins(
return this.$store.getters.isActionActive('workflowRunning'); return this.$store.getters.isActionActive('workflowRunning');
}, },
isTriggerNode (): boolean { isTriggerNode (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('trigger')); return this.$store.getters['nodeTypes/isTriggerNode'](this.node.type);
},
isManualTriggerNode (): boolean {
return Boolean(this.nodeType && this.nodeType.name === MANUAL_TRIGGER_NODE_TYPE);
}, },
isPollingTypeNode (): boolean { isPollingTypeNode (): boolean {
return !!(this.nodeType && this.nodeType.polling); return !!(this.nodeType && this.nodeType.polling);
@ -138,7 +141,7 @@ export default mixins(
return this.$locale.baseText('ndv.execute.fetchEvent'); return this.$locale.baseText('ndv.execute.fetchEvent');
} }
if (this.isTriggerNode && !this.isScheduleTrigger) { if (this.isTriggerNode && !this.isScheduleTrigger && !this.isManualTriggerNode) {
return this.$locale.baseText('ndv.execute.listenForEvent'); return this.$locale.baseText('ndv.execute.listenForEvent');
} }

View file

@ -121,7 +121,7 @@ export default mixins(
return null; return null;
}, },
isTriggerNode (): boolean { isTriggerNode (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('trigger')); return this.$store.getters['nodeTypes/isTriggerNode'](this.node.type);
}, },
isPollingTypeNode (): boolean { isPollingTypeNode (): boolean {
return !!(this.nodeType && this.nodeType.polling); return !!(this.nodeType && this.nodeType.polling);
@ -150,6 +150,8 @@ export default mixins(
return executionData.resultData.runData; return executionData.resultData.runData;
}, },
hasNodeRun(): boolean { hasNodeRun(): boolean {
if (this.$store.getters.subworkflowExecutionError) return true;
return Boolean( return Boolean(
this.node && this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name), this.node && this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name),
); );

View file

@ -158,6 +158,10 @@
</div> </div>
</div> </div>
<div v-else-if="paneType === 'output' && hasSubworkflowExecutionError" :class="$style.stretchVertically">
<NodeErrorView :error="subworkflowExecutionError" :class="$style.errorDisplay" />
</div>
<div v-else-if="!hasNodeRun" :class="$style.center"> <div v-else-if="!hasNodeRun" :class="$style.center">
<slot name="node-not-run"></slot> <slot name="node-not-run"></slot>
</div> </div>
@ -499,7 +503,7 @@ export default mixins(
return null; return null;
}, },
isTriggerNode (): boolean { isTriggerNode (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('trigger')); return this.$store.getters['nodeTypes/isTriggerNode'](this.node.type);
}, },
canPinData (): boolean { canPinData (): boolean {
return !this.isPaneTypeInput && return !this.isPaneTypeInput &&
@ -522,6 +526,12 @@ export default mixins(
hasNodeRun(): boolean { hasNodeRun(): boolean {
return Boolean(!this.isExecuting && this.node && (this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name) || this.hasPinData)); return Boolean(!this.isExecuting && this.node && (this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name) || this.hasPinData));
}, },
subworkflowExecutionError(): Error | null {
return this.$store.getters.subworkflowExecutionError;
},
hasSubworkflowExecutionError(): boolean {
return Boolean(this.subworkflowExecutionError);
},
hasRunError(): boolean { hasRunError(): boolean {
return Boolean(this.node && this.workflowRunData && this.workflowRunData[this.node.name] && this.workflowRunData[this.node.name][this.runIndex] && this.workflowRunData[this.node.name][this.runIndex].error); return Boolean(this.node && this.workflowRunData && this.workflowRunData[this.node.name] && this.workflowRunData[this.node.name][this.runIndex] && this.workflowRunData[this.node.name][this.runIndex].error);
}, },

View file

@ -7,6 +7,7 @@
:loading="isSaving" :loading="isSaving"
:disabled="disabled" :disabled="disabled"
:class="$style.button" :class="$style.button"
:type="type"
@click="$emit('click')" @click="$emit('click')"
/> />
</span> </span>
@ -36,6 +37,10 @@ export default Vue.extend({
savedLabel: { savedLabel: {
type: String, type: String,
}, },
type: {
type: String,
default: 'primary',
},
}, },
computed: { computed: {
saveButtonLabel() { saveButtonLabel() {

View file

@ -1,4 +1,4 @@
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER } from '@/constants'; import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER, NON_ACTIVATABLE_TRIGGER_NODE_TYPES } from '@/constants';
import { INodeUi, ITemplatesNode } from '@/Interface'; import { INodeUi, ITemplatesNode } from '@/Interface';
import { isResourceLocatorValue } from '@/typeGuards'; import { isResourceLocatorValue } from '@/typeGuards';
import dateformat from 'dateformat'; import dateformat from 'dateformat';
@ -49,10 +49,7 @@ export function getTriggerNodeServiceName(nodeType: INodeTypeDescription): strin
} }
export function getActivatableTriggerNodes(nodes: INodeUi[]) { export function getActivatableTriggerNodes(nodes: INodeUi[]) {
return nodes.filter((node: INodeUi) => { return nodes.filter((node: INodeUi) => !node.disabled && !NON_ACTIVATABLE_TRIGGER_NODE_TYPES.includes(node.type));
// Error Trigger does not behave like other triggers and workflows using it can not be activated
return !node.disabled && node.type !== ERROR_TRIGGER_NODE_TYPE;
});
} }
export function filterTemplateNodes(nodes: ITemplatesNode[]) { export function filterTemplateNodes(nodes: ITemplatesNode[]) {

View file

@ -30,6 +30,9 @@
registerCustomAction(key: string, action: Function) { registerCustomAction(key: string, action: Function) {
this.customActions[key] = action; this.customActions[key] = action;
}, },
unregisterCustomAction(key: string) {
Vue.delete(this.customActions, key);
},
delegateClick(e: MouseEvent) { delegateClick(e: MouseEvent) {
const clickedElement = e.target; const clickedElement = e.target;
if (!(clickedElement instanceof Element) || clickedElement.tagName !== 'A') return; if (!(clickedElement instanceof Element) || clickedElement.tagName !== 'A') return;

View file

@ -29,13 +29,31 @@ export const nodeBase = mixins(
return this.data.id; return this.data.id;
}, },
}, },
props: [ props: {
'name', name: {
'instance', type: String,
'isReadOnly', },
'isActive', instance: {
'hideActions', // We can't use PropType<jsPlumbInstance> here because the version of jsplumb doesn't
], // include correct typing for draggable instance(`clearDragSelection`, `destroyDraggable`, etc.)
type: Object,
},
isReadOnly: {
type: Boolean,
},
isActive: {
type: Boolean,
},
hideActions: {
type: Boolean,
},
disableSelecting: {
type: Boolean,
},
showCustomTooltip: {
type: Boolean,
},
},
methods: { methods: {
__addInputEndpoints (node: INodeUi, nodeTypeData: INodeTypeDescription) { __addInputEndpoints (node: INodeUi, nodeTypeData: INodeTypeDescription) {
// Add Inputs // Add Inputs

View file

@ -14,6 +14,7 @@ import {
IDataObject, IDataObject,
INodeTypeNameVersion, INodeTypeNameVersion,
IWorkflowBase, IWorkflowBase,
SubworkflowOperationError,
TelemetryHelpers, TelemetryHelpers,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -291,19 +292,34 @@ export const pushConnection = mixins(
} }
let title: string; if (runDataExecuted.data.resultData.error?.name === 'SubworkflowOperationError') {
if (runDataExecuted.data.resultData.lastNodeExecuted) { const error = runDataExecuted.data.resultData.error as SubworkflowOperationError;
title = `Problem in node ${runDataExecuted.data.resultData.lastNodeExecuted}`;
this.$store.commit('setSubworkflowExecutionError', error);
this.$showMessage({
title: error.message,
message: error.description,
type: 'error',
duration: 0,
});
} else { } else {
title = 'Problem executing workflow'; let title: string;
if (runDataExecuted.data.resultData.lastNodeExecuted) {
title = `Problem in node ${runDataExecuted.data.resultData.lastNodeExecuted}`;
} else {
title = 'Problem executing workflow';
}
this.$showMessage({
title,
message: runDataExecutedErrorMessage,
type: 'error',
duration: 0,
});
} }
this.$showMessage({
title,
message: runDataExecutedErrorMessage,
type: 'error',
duration: 0,
});
} else { } else {
// Workflow did execute without a problem // Workflow did execute without a problem
this.$titleSet(workflow.name as string, 'IDLE'); this.$titleSet(workflow.name as string, 'IDLE');

View file

@ -38,6 +38,8 @@ export const workflowRun = mixins(
); );
} }
this.$store.commit('setSubworkflowExecutionError', null);
this.$store.commit('addActiveAction', 'workflowRunning'); this.$store.commit('addActiveAction', 'workflowRunning');
let response: IExecutionPushResponse; let response: IExecutionPushResponse;

View file

@ -1,6 +1,6 @@
<template> <template>
<transition name="slide"> <transition name="slide">
<slot></slot> <slot />
</transition> </transition>
</template> </template>
@ -21,4 +21,5 @@ export default Vue.extend({
.slide-enter { .slide-enter {
transform: translateX(100%); transform: translateX(100%);
} }
</style>
</style>

View file

@ -92,6 +92,7 @@ export const ITEM_LISTS_NODE_TYPE = 'n8n-nodes-base.itemLists';
export const JIRA_NODE_TYPE = 'n8n-nodes-base.jira'; export const JIRA_NODE_TYPE = 'n8n-nodes-base.jira';
export const JIRA_TRIGGER_NODE_TYPE = 'n8n-nodes-base.jiraTrigger'; export const JIRA_TRIGGER_NODE_TYPE = 'n8n-nodes-base.jiraTrigger';
export const MICROSOFT_EXCEL_NODE_TYPE = 'n8n-nodes-base.microsoftExcel'; export const MICROSOFT_EXCEL_NODE_TYPE = 'n8n-nodes-base.microsoftExcel';
export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger';
export const MICROSOFT_TEAMS_NODE_TYPE = 'n8n-nodes-base.microsoftTeams'; export const MICROSOFT_TEAMS_NODE_TYPE = 'n8n-nodes-base.microsoftTeams';
export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp'; export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp';
export const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote'; export const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote';
@ -110,11 +111,19 @@ export const THE_HIVE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.theHiveTrigger';
export const QUICKBOOKS_NODE_TYPE = 'n8n-nodes-base.quickbooks'; export const QUICKBOOKS_NODE_TYPE = 'n8n-nodes-base.quickbooks';
export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook'; export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook';
export const WORKABLE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.workableTrigger'; export const WORKABLE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.workableTrigger';
export const WORKFLOW_TRIGGER_NODE_TYPE = 'n8n-nodes-base.workflowTrigger';
export const EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE = 'n8n-nodes-base.executeWorkflowTrigger';
export const WOOCOMMERCE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.wooCommerceTrigger'; export const WOOCOMMERCE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.wooCommerceTrigger';
export const XERO_NODE_TYPE = 'n8n-nodes-base.xero'; export const XERO_NODE_TYPE = 'n8n-nodes-base.xero';
export const ZENDESK_NODE_TYPE = 'n8n-nodes-base.zendesk'; export const ZENDESK_NODE_TYPE = 'n8n-nodes-base.zendesk';
export const ZENDESK_TRIGGER_NODE_TYPE = 'n8n-nodes-base.zendeskTrigger'; export const ZENDESK_TRIGGER_NODE_TYPE = 'n8n-nodes-base.zendeskTrigger';
export const NON_ACTIVATABLE_TRIGGER_NODE_TYPES = [
ERROR_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
];
export const MULTIPLE_OUTPUT_NODE_TYPES = [ export const MULTIPLE_OUTPUT_NODE_TYPES = [
IF_NODE_TYPE, IF_NODE_TYPE,
SWITCH_NODE_TYPE, SWITCH_NODE_TYPE,
@ -127,6 +136,7 @@ export const PIN_DATA_NODE_TYPES_DENYLIST = [
// Node creator // Node creator
export const CORE_NODES_CATEGORY = 'Core Nodes'; export const CORE_NODES_CATEGORY = 'Core Nodes';
export const COMMUNICATION_CATEGORY = 'Communication';
export const CUSTOM_NODES_CATEGORY = 'Custom Nodes'; export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
export const SUBCATEGORY_DESCRIPTIONS: { export const SUBCATEGORY_DESCRIPTIONS: {
[category: string]: { [subcategory: string]: string }; [category: string]: { [subcategory: string]: string };
@ -144,6 +154,7 @@ export const ALL_NODE_FILTER = 'All';
export const UNCATEGORIZED_CATEGORY = 'Miscellaneous'; export const UNCATEGORIZED_CATEGORY = 'Miscellaneous';
export const UNCATEGORIZED_SUBCATEGORY = 'Helpers'; export const UNCATEGORIZED_SUBCATEGORY = 'Helpers';
export const PERSONALIZED_CATEGORY = 'Suggested Nodes'; export const PERSONALIZED_CATEGORY = 'Suggested Nodes';
export const OTHER_TRIGGER_NODES_SUBCATEGORY = 'Other Trigger Nodes';
export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3'; export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3';
@ -262,6 +273,11 @@ export const HIRING_BANNER = `
Love n8n? Help us build the future of automation! https://n8n.io/careers Love n8n? Help us build the future of automation! https://n8n.io/careers
`; `;
export const NODE_TYPE_COUNT_MAPPER = {
[REGULAR_NODE_FILTER]: ['regularCount'],
[TRIGGER_NODE_FILTER]: ['triggerCount'],
[ALL_NODE_FILTER]: ['triggerCount', 'regularCount'],
};
export const TEMPLATES_NODES_FILTER = [ export const TEMPLATES_NODES_FILTER = [
'n8n-nodes-base.start', 'n8n-nodes-base.start',
'n8n-nodes-base.respondToWebhook', 'n8n-nodes-base.respondToWebhook',

View file

@ -0,0 +1,39 @@
import { ALL_NODE_FILTER } from '@/constants';
import { Module } from 'vuex';
import {
IRootState,
INodeCreatorState,
INodeFilterType,
} from '@/Interface';
const module: Module<INodeCreatorState, IRootState> = {
namespaced: true,
state: {
itemsFilter: '',
showTabs: true,
showScrim: false,
selectedType: ALL_NODE_FILTER,
},
getters: {
showTabs: (state: INodeCreatorState) => state.showTabs,
showScrim: (state: INodeCreatorState) => state.showScrim,
selectedType: (state: INodeCreatorState) => state.selectedType,
itemsFilter: (state: INodeCreatorState) => state.itemsFilter,
},
mutations: {
setShowTabs(state: INodeCreatorState, isVisible: boolean) {
state.showTabs = isVisible;
},
setShowScrim(state: INodeCreatorState, isVisible: boolean) {
state.showScrim = isVisible;
},
setSelectedType(state: INodeCreatorState, selectedNodeType: INodeFilterType) {
state.selectedType = selectedNodeType;
},
setFilter(state: INodeCreatorState, search: INodeFilterType) {
state.itemsFilter = search;
},
},
};
export default module;

View file

@ -19,7 +19,8 @@ import {
getResourceLocatorResults, getResourceLocatorResults,
} from '@/api/nodeTypes'; } from '@/api/nodeTypes';
import { omit } from '@/utils'; import { omit } from '@/utils';
import type { IRootState, INodeTypesState, IResourceLocatorReqParams } from '../Interface'; import type { IRootState, INodeTypesState, ICategoriesWithNodes, INodeCreateElement, IResourceLocatorReqParams } from '../Interface';
import { getCategoriesWithNodes, getCategorizedList } from './nodeTypesHelpers';
const module: Module<INodeTypesState, IRootState> = { const module: Module<INodeTypesState, IRootState> = {
namespaced: true, namespaced: true,
@ -55,6 +56,19 @@ const module: Module<INodeTypesState, IRootState> = {
return nodeType || null; return nodeType || null;
}, },
isTriggerNode: (state, getters) => (nodeTypeName: string) => {
const nodeType = getters.getNodeType(nodeTypeName);
return !!(nodeType && nodeType.group.includes('trigger'));
},
visibleNodeTypes: (state, getters): INodeTypeDescription[] => {
return getters.allLatestNodeTypes.filter((nodeType: INodeTypeDescription) => !nodeType.hidden);
},
categoriesWithNodes: (state, getters, rootState, rootGetters): ICategoriesWithNodes => {
return getCategoriesWithNodes(getters.visibleNodeTypes, rootGetters['users/personalizedNodeTypes']);
},
categorizedItems: (state, getters): INodeCreateElement[] => {
return getCategorizedList(getters.categoriesWithNodes);
},
}, },
mutations: { mutations: {
setNodeTypes(state, newNodeTypes: INodeTypeDescription[] = []) { setNodeTypes(state, newNodeTypes: INodeTypeDescription[] = []) {

View file

@ -0,0 +1,150 @@
import { CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, SUBCATEGORY_DESCRIPTIONS, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY, PERSONALIZED_CATEGORY } from '@/constants';
import { INodeCreateElement, ICategoriesWithNodes } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescription, category: string, subcategory: string) => {
if (!accu[category]) {
accu[category] = {};
}
if (!accu[category][subcategory]) {
accu[category][subcategory] = {
triggerCount: 0,
regularCount: 0,
nodes: [],
};
}
const isTrigger = nodeType.group.includes('trigger');
if (isTrigger) {
accu[category][subcategory].triggerCount++;
}
if (!isTrigger) {
accu[category][subcategory].regularCount++;
}
accu[category][subcategory].nodes.push({
type: 'node',
key: `${category}_${nodeType.name}`,
category,
properties: {
nodeType,
subcategory,
},
includedByTrigger: isTrigger,
includedByRegular: !isTrigger,
});
};
export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[], personalizedNodeTypes: string[]): ICategoriesWithNodes => {
const sorted = [...nodeTypes].sort((a: INodeTypeDescription, b: INodeTypeDescription) => a.displayName > b.displayName? 1 : -1);
return sorted.reduce(
(accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => {
if (personalizedNodeTypes.includes(nodeType.name)) {
addNodeToCategory(accu, nodeType, PERSONALIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
}
if (!nodeType.codex || !nodeType.codex.categories) {
addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
return accu;
}
nodeType.codex.categories.forEach((_category: string) => {
const category = _category.trim();
const subcategories =
nodeType.codex &&
nodeType.codex.subcategories &&
nodeType.codex.subcategories[category]
? nodeType.codex.subcategories[category]
: null;
if(subcategories === null || subcategories.length === 0) {
addNodeToCategory(accu, nodeType, category, UNCATEGORIZED_SUBCATEGORY);
return;
}
subcategories.forEach(subcategory => {
addNodeToCategory(accu, nodeType, category, subcategory);
});
});
return accu;
},
{},
);
};
const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
const excludeFromSort = [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, UNCATEGORIZED_CATEGORY, PERSONALIZED_CATEGORY];
const categories = Object.keys(categoriesWithNodes);
const sorted = categories.filter(
(category: string) =>
!excludeFromSort.includes(category),
);
sorted.sort();
return [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, PERSONALIZED_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY];
};
export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): INodeCreateElement[] => {
const categories = getCategories(categoriesWithNodes);
return categories.reduce(
(accu: INodeCreateElement[], category: string) => {
if (!categoriesWithNodes[category]) {
return accu;
}
const categoryEl: INodeCreateElement = {
type: 'category',
key: category,
category,
properties: {
expanded: false,
},
};
const subcategories = Object.keys(categoriesWithNodes[category]);
if (subcategories.length === 1) {
const subcategory = categoriesWithNodes[category][
subcategories[0]
];
if (subcategory.triggerCount > 0) {
categoryEl.includedByTrigger = subcategory.triggerCount > 0;
}
if (subcategory.regularCount > 0) {
categoryEl.includedByRegular = subcategory.regularCount > 0;
}
return [...accu, categoryEl, ...subcategory.nodes];
}
subcategories.sort();
const subcategorized = subcategories.reduce(
(accu: INodeCreateElement[], subcategory: string) => {
const subcategoryEl: INodeCreateElement = {
type: 'subcategory',
key: `${category}_${subcategory}`,
category,
properties: {
subcategory,
description: SUBCATEGORY_DESCRIPTIONS[category][subcategory],
},
includedByTrigger: categoriesWithNodes[category][subcategory].triggerCount > 0,
includedByRegular: categoriesWithNodes[category][subcategory].regularCount > 0,
};
if (subcategoryEl.includedByTrigger) {
categoryEl.includedByTrigger = true;
}
if (subcategoryEl.includedByRegular) {
categoryEl.includedByRegular = true;
}
accu.push(subcategoryEl);
return accu;
},
[],
);
return [...accu, categoryEl, ...subcategorized];
},
[],
);
};

View file

@ -23,17 +23,17 @@ import {
ONBOARDING_CALL_SIGNUP_MODAL_KEY, ONBOARDING_CALL_SIGNUP_MODAL_KEY,
FAKE_DOOR_FEATURES, FAKE_DOOR_FEATURES,
COMMUNITY_PACKAGE_MANAGE_ACTIONS, COMMUNITY_PACKAGE_MANAGE_ACTIONS,
ALL_NODE_FILTER,
IMPORT_CURL_MODAL_KEY, IMPORT_CURL_MODAL_KEY,
} from '@/constants'; } from '@/constants';
import Vue from 'vue'; import Vue from 'vue';
import { ActionContext, Module } from 'vuex'; import { ActionContext, Module } from 'vuex';
import { import {
IExecutionResponse,
IFakeDoor,
IFakeDoorLocation, IFakeDoorLocation,
IRootState, IRootState,
IRunDataDisplayMode, IRunDataDisplayMode,
IUiState, IUiState,
INodeFilterType,
XYPosition, XYPosition,
} from '../Interface'; } from '../Interface';
@ -329,6 +329,9 @@ const module: Module<IUiState, IRootState> = {
setOutputPanelEditModeValue: (state: IUiState, payload: string) => { setOutputPanelEditModeValue: (state: IUiState, payload: string) => {
Vue.set(state.ndv.output.editMode, 'value', payload); Vue.set(state.ndv.output.editMode, 'value', payload);
}, },
setMainPanelRelativePosition(state: IUiState, relativePosition: number) {
state.mainPanelPosition = relativePosition;
},
setMappableNDVInputFocus(state: IUiState, paramName: string) { setMappableNDVInputFocus(state: IUiState, paramName: string) {
Vue.set(state.ndv, 'focusedMappableInput', paramName); Vue.set(state.ndv, 'focusedMappableInput', paramName);
}, },

View file

@ -61,6 +61,7 @@ $node-creator-item-hover-border-color: var(--color-text-light);
$node-creator-arrow-color: var(--color-text-light); $node-creator-arrow-color: var(--color-text-light);
$node-creator-no-results-background-color: var(--color-background-xlight); $node-creator-no-results-background-color: var(--color-background-xlight);
$node-creator-close-button-color: var(--color-text-xlight); $node-creator-close-button-color: var(--color-text-xlight);
$node-creator-search-clear-color: var(--color-text-xlight);
$node-creator-search-clear-background-color: var(--color-text-light); $node-creator-search-clear-background-color: var(--color-text-light);
$node-creator-search-clear-background-color-hover: var(--color-text-base); $node-creator-search-clear-background-color-hover: var(--color-text-base);
$node-creator-search-placeholder-color: var(--color-text-light); $node-creator-search-placeholder-color: var(--color-text-light);

View file

@ -636,16 +636,31 @@
"nodeCreator.noResults.requestTheNode": "Request the node", "nodeCreator.noResults.requestTheNode": "Request the node",
"nodeCreator.noResults.wantUsToMakeItFaster": "Want us to make it faster?", "nodeCreator.noResults.wantUsToMakeItFaster": "Want us to make it faster?",
"nodeCreator.noResults.weDidntMakeThatYet": "We didn't make that... yet", "nodeCreator.noResults.weDidntMakeThatYet": "We didn't make that... yet",
"nodeCreator.noResults.clickToSeeResults": "To see results, <a data-action='showAllNodeCreatorNodes'>click here</a>",
"nodeCreator.noResults.webhook": "Webhook", "nodeCreator.noResults.webhook": "Webhook",
"nodeCreator.searchBar.searchNodes": "Search nodes...", "nodeCreator.searchBar.searchNodes": "Search nodes...",
"nodeCreator.subcategoryDescriptions.appTriggerNodes": "Runs the flow when something happens in an app like Telegram, Notion or Airtable",
"nodeCreator.subcategoryDescriptions.dataTransformation": "Manipulate data fields, run code", "nodeCreator.subcategoryDescriptions.dataTransformation": "Manipulate data fields, run code",
"nodeCreator.subcategoryDescriptions.files": "Work with CSV, XML, text, images etc.", "nodeCreator.subcategoryDescriptions.files": "Work with CSV, XML, text, images etc.",
"nodeCreator.subcategoryDescriptions.flow": "Branches, core triggers, merge data", "nodeCreator.subcategoryDescriptions.flow": "Branches, core triggers, merge data",
"nodeCreator.subcategoryDescriptions.helpers": "HTTP Requests (API calls), date and time, scrape HTML", "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.subcategoryNames.dataTransformation": "Data Transformation",
"nodeCreator.subcategoryNames.files": "Files", "nodeCreator.subcategoryNames.files": "Files",
"nodeCreator.subcategoryNames.flow": "Flow", "nodeCreator.subcategoryNames.flow": "Flow",
"nodeCreator.subcategoryNames.helpers": "Helpers", "nodeCreator.subcategoryNames.helpers": "Helpers",
"nodeCreator.subcategoryNames.otherTriggerNodes": "Other ways...",
"nodeCreator.subcategoryTitles.otherTriggerNodes": "Other Trigger Nodes",
"nodeCreator.triggerHelperPanel.title": "When should this workflow run?",
"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.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.workflowTriggerDescription": "Runs the flow when called by the Execute Workflow node from a different workflow",
"nodeCredentials.createNew": "Create New", "nodeCredentials.createNew": "Create New",
"nodeCredentials.credentialFor": "Credential for {credentialType}", "nodeCredentials.credentialFor": "Credential for {credentialType}",
"nodeCredentials.issues": "Issues", "nodeCredentials.issues": "Issues",
@ -702,14 +717,19 @@
"nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)", "nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)",
"nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)", "nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)",
"nodeView.addNode": "Add node", "nodeView.addNode": "Add node",
"nodeView.addATriggerNodeFirst": "Add a <a data-action='showNodeCreator'>Trigger Node</a> first",
"nodeView.addOrEnableTriggerNode": "<a data-action='showNodeCreator'>Add</a> or enable a Trigger node to execute the workflow",
"nodeView.addSticky": "Click to add sticky note", "nodeView.addSticky": "Click to add sticky note",
"nodeView.cantExecuteNoTrigger": "Cannot execute workflow",
"nodeView.canvasAddButton.addATriggerNodeBeforeExecuting": "Add a Trigger Node before executing the workflow",
"nodeView.canvasAddButton.addFirstStep": "Add first step…",
"nodeView.confirmMessage.receivedCopyPasteData.cancelButtonText": "", "nodeView.confirmMessage.receivedCopyPasteData.cancelButtonText": "",
"nodeView.confirmMessage.receivedCopyPasteData.confirmButtonText": "Yes, import", "nodeView.confirmMessage.receivedCopyPasteData.confirmButtonText": "Yes, import",
"nodeView.confirmMessage.receivedCopyPasteData.headline": "Import Workflow?", "nodeView.confirmMessage.receivedCopyPasteData.headline": "Import Workflow?",
"nodeView.confirmMessage.receivedCopyPasteData.message": "Workflow will be imported from<br /><i>{plainTextData}<i>", "nodeView.confirmMessage.receivedCopyPasteData.message": "Workflow will be imported from<br /><i>{plainTextData}<i>",
"nodeView.couldntImportWorkflow": "Could not import workflow", "nodeView.couldntImportWorkflow": "Could not import workflow",
"nodeView.deletesTheCurrentExecutionData": "Deletes the current execution data", "nodeView.deletesTheCurrentExecutionData": "Deletes the current execution data",
"nodeView.executesTheWorkflowFromTheStartOrWebhookNode": "Executes the workflow from the 'start' or 'webhook' node", "nodeView.executesTheWorkflowFromATriggerNode": "Runs the workflow, starting from a Trigger node",
"nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.", "nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.",
"nodeView.loadingTemplate": "Loading template", "nodeView.loadingTemplate": "Loading template",
"nodeView.moreInfo": "More info", "nodeView.moreInfo": "More info",
@ -736,10 +756,10 @@
"nodeView.showError.stopExecution.title": "Problem stopping execution", "nodeView.showError.stopExecution.title": "Problem stopping execution",
"nodeView.showError.stopWaitingForWebhook.title": "Problem deleting test webhook", "nodeView.showError.stopWaitingForWebhook.title": "Problem deleting test webhook",
"nodeView.showMessage.addNodeButton.message": "'{nodeTypeName}' is an unknown node type", "nodeView.showMessage.addNodeButton.message": "'{nodeTypeName}' is an unknown node type",
"nodeView.showMessage.addNodeButton.title": "Could not create node", "nodeView.showMessage.addNodeButton.title": "Could not insert node",
"nodeView.showMessage.keyDown.title": "Workflow created", "nodeView.showMessage.keyDown.title": "Workflow created",
"nodeView.showMessage.showMaxNodeTypeError.message": "Only {count} '{nodeTypeDataDisplayName}' node is allowed in a workflow | Only {count} '{nodeTypeDataDisplayName}' nodes are allowed in a workflow", "nodeView.showMessage.showMaxNodeTypeError.message": "Only one '{nodeTypeDataDisplayName}' node is allowed in a workflow | Only {count} '{nodeTypeDataDisplayName}' nodes are allowed in a workflow",
"nodeView.showMessage.showMaxNodeTypeError.title": "Could not create node", "nodeView.showMessage.showMaxNodeTypeError.title": "Could not insert node",
"nodeView.showMessage.stopExecutionCatch.message": "It completed before it could be stopped", "nodeView.showMessage.stopExecutionCatch.message": "It completed before it could be stopped",
"nodeView.showMessage.stopExecutionCatch.title": "Workflow finished executing", "nodeView.showMessage.stopExecutionCatch.title": "Workflow finished executing",
"nodeView.showMessage.stopExecutionTry.title": "Execution stopped", "nodeView.showMessage.stopExecutionTry.title": "Execution stopped",

View file

@ -67,6 +67,7 @@ import {
faLink, faLink,
faLightbulb, faLightbulb,
faMapSigns, faMapSigns,
faMousePointer,
faNetworkWired, faNetworkWired,
faPause, faPause,
faPauseCircle, faPauseCircle,
@ -83,11 +84,13 @@ import {
faRedo, faRedo,
faRss, faRss,
faSave, faSave,
faSatelliteDish,
faSearch, faSearch,
faSearchMinus, faSearchMinus,
faSearchPlus, faSearchPlus,
faServer, faServer,
faSignInAlt, faSignInAlt,
faSignOutAlt,
faSlidersH, faSlidersH,
faSpinner, faSpinner,
faStop, faStop,
@ -185,6 +188,7 @@ addIcon(faKey);
addIcon(faLink); addIcon(faLink);
addIcon(faLightbulb); addIcon(faLightbulb);
addIcon(faMapSigns); addIcon(faMapSigns);
addIcon(faMousePointer);
addIcon(faNetworkWired); addIcon(faNetworkWired);
addIcon(faPause); addIcon(faPause);
addIcon(faPauseCircle); addIcon(faPauseCircle);
@ -201,11 +205,13 @@ addIcon(faQuestionCircle);
addIcon(faRedo); addIcon(faRedo);
addIcon(faRss); addIcon(faRss);
addIcon(faSave); addIcon(faSave);
addIcon(faSatelliteDish);
addIcon(faSearch); addIcon(faSearch);
addIcon(faSearchMinus); addIcon(faSearchMinus);
addIcon(faSearchPlus); addIcon(faSearchPlus);
addIcon(faServer); addIcon(faServer);
addIcon(faSignInAlt); addIcon(faSignInAlt);
addIcon(faSignOutAlt);
addIcon(faSlidersH); addIcon(faSlidersH);
addIcon(faSpinner); addIcon(faSpinner);
addIcon(faSolidStickyNote); addIcon(faSolidStickyNote);

View file

@ -46,6 +46,7 @@ import templates from './modules/templates';
import {stringSizeInBytes} from "@/components/helpers"; import {stringSizeInBytes} from "@/components/helpers";
import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus"; import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus";
import communityNodes from './modules/communityNodes'; import communityNodes from './modules/communityNodes';
import nodeCreator from './modules/nodeCreator';
import { isJsonKeyObject } from './utils'; import { isJsonKeyObject } from './utils';
import { getPairedItemsMapping } from './pairedItemUtils'; import { getPairedItemsMapping } from './pairedItemUtils';
@ -102,6 +103,7 @@ const state: IRootState = {
sidebarMenuItems: [], sidebarMenuItems: [],
instanceId: '', instanceId: '',
nodeMetadata: {}, nodeMetadata: {},
subworkflowExecutionError: null,
}; };
const modules = { const modules = {
@ -115,6 +117,7 @@ const modules = {
users, users,
ui, ui,
communityNodes, communityNodes,
nodeCreator,
}; };
export const store = new Vuex.Store({ export const store = new Vuex.Store({
@ -171,6 +174,9 @@ export const store = new Vuex.Store({
Vue.set(activeExecution, 'finished', finishedActiveExecution.data.finished); Vue.set(activeExecution, 'finished', finishedActiveExecution.data.finished);
Vue.set(activeExecution, 'stoppedAt', finishedActiveExecution.data.stoppedAt); Vue.set(activeExecution, 'stoppedAt', finishedActiveExecution.data.stoppedAt);
}, },
setSubworkflowExecutionError(state, subworkflowExecutionError: Error | null) {
state.subworkflowExecutionError = subworkflowExecutionError;
},
setActiveExecutions(state, newActiveExecutions: IExecutionsCurrentSummaryExtended[]) { setActiveExecutions(state, newActiveExecutions: IExecutionsCurrentSummaryExtended[]) {
Vue.set(state, 'activeExecutions', newActiveExecutions); Vue.set(state, 'activeExecutions', newActiveExecutions);
}, },
@ -724,6 +730,10 @@ export const store = new Vuex.Store({
return state.activeCredentialType; return state.activeCredentialType;
}, },
subworkflowExecutionError: (state): Error | null => {
return state.subworkflowExecutionError;
},
isActionActive: (state) => (action: string): boolean => { isActionActive: (state) => (action: string): boolean => {
return state.activeActions.includes(action); return state.activeActions.includes(action);
}, },

View file

@ -0,0 +1,95 @@
<template>
<div :class="$style.container" :style="containerCssVars" ref="container">
<n8n-tooltip placement="top" :value="showTooltip" manual :disabled="isScrimActive" :popper-class="$style.tooltip" :open-delay="700">
<button :class="$style.button" @click="$emit('click')">
<font-awesome-icon icon="plus" size="lg" />
</button>
<template #content>
<p v-text="$locale.baseText('nodeView.canvasAddButton.addATriggerNodeBeforeExecuting')" />
</template>
</n8n-tooltip>
<p :class="$style.label" v-text="$locale.baseText('nodeView.canvasAddButton.addFirstStep')" />
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { XYPosition } from '@/Interface';
export default Vue.extend({
name: 'CanvasAddButton',
props: {
showTooltip: {
type: Boolean,
},
position: {
type: Array,
},
},
computed: {
containerCssVars(): Record<string, string> {
const position = this.position as XYPosition;
return {
'--trigger-placeholder-left-position': `${position[0]}px`,
'--trigger-placeholder-top-position': `${position[1]}px`,
};
},
isScrimActive(): boolean {
return this.$store.getters['nodeCreator/showScrim'];
},
},
});
</script>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
position: absolute;
top: var(--trigger-placeholder-top-position);
left: var(--trigger-placeholder-left-position);
// We have to increase z-index to make sure it's higher than selecting box in NodeView
// otherwise the clics wouldn't register
z-index: 101;
&:hover .button svg path {
fill: var(--color-primary)
}
}
.button {
background: var(--color-foreground-xlight);
border: 2px dashed var(--color-foreground-xdark);
border-radius: 8px;
padding: 0;
min-width: 100px;
min-height: 100px;
cursor: pointer;
svg {
width: 26px !important;
height: 40px;
path {
fill: var(--color-foreground-xdark)
}
}
}
.tooltip {
max-width: 180px;
}
.label {
width: max-content;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-m);
line-height: var(--font-line-height-xloose );
color: var(--color-text-dark);
margin-top: var(--spacing-2xs);
}
</style>

View file

@ -4,22 +4,73 @@
@touchmove="mouseMoveNodeWorkflow" @mousedown="mouseDown" v-touch:tap="touchTap" @mouseup="mouseUp" @touchmove="mouseMoveNodeWorkflow" @mousedown="mouseDown" v-touch:tap="touchTap" @mouseup="mouseUp"
@wheel="wheelScroll"> @wheel="wheelScroll">
<div id="node-view-background" class="node-view-background" :style="backgroundStyle" /> <div id="node-view-background" class="node-view-background" :style="backgroundStyle" />
<div id="node-view" class="node-view" :style="workflowStyle"> <div
id="node-view"
class="node-view"
:style="workflowStyle"
ref="nodeView"
>
<canvas-add-button
:style="canvasAddButtonStyle"
@click="showTriggerCreator('tirger_placeholder_button')"
v-show="showCanvasAddButton"
:showTooltip="!containsTrigger && showTriggerMissingTooltip"
:position="canvasAddButtonPosition"
@hook:mounted="setRecenteredCanvasAddButtonPosition"
/>
<div v-for="nodeData in nodes" :key="nodeData.id"> <div v-for="nodeData in nodes" :key="nodeData.id">
<node v-if="nodeData.type !== STICKY_NODE_TYPE" @duplicateNode="duplicateNode" <node
@deselectAllNodes="deselectAllNodes" @deselectNode="nodeDeselectedByName" @nodeSelected="nodeSelectedByName" v-if="nodeData.type !== STICKY_NODE_TYPE"
@removeNode="removeNode" @runWorkflow="onRunNode" @moved="onNodeMoved" @run="onNodeRun" :key="nodeData.id" @duplicateNode="duplicateNode"
:name="nodeData.name" :isReadOnly="isReadOnly" :instance="instance" @deselectAllNodes="deselectAllNodes"
:isActive="!!activeNode && activeNode.name === nodeData.name" :hideActions="pullConnActive" /> @deselectNode="nodeDeselectedByName"
<Sticky v-else @deselectAllNodes="deselectAllNodes" @deselectNode="nodeDeselectedByName" @nodeSelected="nodeSelectedByName"
@nodeSelected="nodeSelectedByName" @removeNode="removeNode" :key="nodeData.id" :name="nodeData.name" @removeNode="removeNode"
:isReadOnly="isReadOnly" :instance="instance" :isActive="!!activeNode && activeNode.name === nodeData.name" @runWorkflow="onRunNode"
:nodeViewScale="nodeViewScale" :gridSize="GRID_SIZE" :hideActions="pullConnActive" /> @moved="onNodeMoved"
@run="onNodeRun"
:key="`${nodeData.id}_node`"
:name="nodeData.name"
:isReadOnly="isReadOnly"
:instance="instance"
:isActive="!!activeNode && activeNode.name === nodeData.name"
:hideActions="pullConnActive"
>
<span
slot="custom-tooltip"
v-text="$locale.baseText('nodeView.placeholderNode.addTriggerNodeBeforeExecuting')"
/>
</node>
<sticky
v-else
@deselectAllNodes="deselectAllNodes"
@deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName"
@removeNode="removeNode"
:key="`${nodeData.id}_sticky`"
:name="nodeData.name"
:isReadOnly="isReadOnly"
:instance="instance"
:isActive="!!activeNode && activeNode.name === nodeData.name"
:nodeViewScale="nodeViewScale"
:gridSize="GRID_SIZE"
:hideActions="pullConnActive"
/>
</div> </div>
</div> </div>
</div> </div>
<NodeDetailsView :readOnly="isReadOnly" :renaming="renamingActive" @valueChanged="valueChanged" /> <node-details-view
<node-creation v-if="!isReadOnly" :create-node-active="createNodeActive" :node-view-scale="nodeViewScale" @toggleNodeCreator="onToggleNodeCreator" @addNode="onAddNode"/> :readOnly="isReadOnly"
:renaming="renamingActive"
@valueChanged="valueChanged"
/>
<node-creation
v-if="!isReadOnly"
:create-node-active="createNodeActive"
:node-view-scale="nodeViewScale"
@toggleNodeCreator="onToggleNodeCreator"
@addNode="onAddNode"
/>
<div <div
:class="{ 'zoom-menu': true, 'regular-zoom-menu': !isDemo, 'demo-zoom-menu': isDemo, expanded: !sidebarMenuCollapsed }"> :class="{ 'zoom-menu': true, 'regular-zoom-menu': !isDemo, 'demo-zoom-menu': isDemo, expanded: !sidebarMenuCollapsed }">
<n8n-icon-button @click="zoomToFit" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomToFit')" <n8n-icon-button @click="zoomToFit" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomToFit')"
@ -31,10 +82,25 @@
<n8n-icon-button v-if="nodeViewScale !== 1 && !isDemo" @click="resetZoom" type="tertiary" size="large" <n8n-icon-button v-if="nodeViewScale !== 1 && !isDemo" @click="resetZoom" type="tertiary" size="large"
:title="$locale.baseText('nodeView.resetZoom')" icon="undo" /> :title="$locale.baseText('nodeView.resetZoom')" icon="undo" />
</div> </div>
<div class="workflow-execute-wrapper" v-if="!isReadOnly"> <div
<n8n-button @click.stop="onRunWorkflow" :loading="workflowRunning" :label="runButtonText" class="workflow-execute-wrapper" v-if="!isReadOnly"
:title="$locale.baseText('nodeView.executesTheWorkflowFromTheStartOrWebhookNode')" size="large" >
icon="play-circle" type="primary" /> <span
@mouseenter="showTriggerMissingToltip(true)"
@mouseleave="showTriggerMissingToltip(false)"
@click="onRunContainerClick"
>
<n8n-button
@click.stop="onRunWorkflow"
:loading="workflowRunning"
:label="runButtonText"
:title="$locale.baseText('nodeView.executesTheWorkflowFromATriggerNode')"
size="large"
icon="play-circle"
type="primary"
:disabled="isExecutionDisabled"
/>
</span>
<n8n-icon-button v-if="workflowRunning === true && !executionWaitingForWebhook" icon="stop" size="large" <n8n-icon-button v-if="workflowRunning === true && !executionWaitingForWebhook" icon="stop" size="large"
class="stop-execution" type="secondary" :title="stopExecutionInProgress class="stop-execution" type="secondary" :title="stopExecutionInProgress
@ -46,7 +112,7 @@
icon="stop" size="large" :title="$locale.baseText('nodeView.stopWaitingForWebhookCall')" type="secondary" icon="stop" size="large" :title="$locale.baseText('nodeView.stopWaitingForWebhookCall')" type="secondary"
@click.stop="stopWaitingForWebhook()" /> @click.stop="stopWaitingForWebhook()" />
<n8n-icon-button v-if="!isReadOnly && workflowExecution && !workflowRunning" <n8n-icon-button v-if="!isReadOnly && workflowExecution && !workflowRunning && !allTriggersDisabled"
:title="$locale.baseText('nodeView.deletesTheCurrentExecutionData')" icon="trash" size="large" :title="$locale.baseText('nodeView.deletesTheCurrentExecutionData')" icon="trash" size="large"
@click.stop="clearExecutionData()" /> @click.stop="clearExecutionData()" />
</div> </div>
@ -60,6 +126,8 @@ import {
} from 'jsplumb'; } from 'jsplumb';
import type { MessageBoxInputData } from 'element-ui/types/message-box'; import type { MessageBoxInputData } from 'element-ui/types/message-box';
import { jsPlumb, OnConnectionBindInfo } from 'jsplumb'; import { jsPlumb, OnConnectionBindInfo } from 'jsplumb';
import once from 'lodash/once';
import { import {
FIRST_ONBOARDING_PROMPT_TIMEOUT, FIRST_ONBOARDING_PROMPT_TIMEOUT,
MODAL_CANCEL, MODAL_CANCEL,
@ -75,6 +143,7 @@ import {
VIEWS, VIEWS,
WEBHOOK_NODE_TYPE, WEBHOOK_NODE_TYPE,
WORKFLOW_OPEN_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY,
TRIGGER_NODE_FILTER,
} from '@/constants'; } from '@/constants';
import { copyPaste } from '@/components/mixins/copyPaste'; import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
@ -82,6 +151,7 @@ import { genericHelpers } from '@/components/mixins/genericHelpers';
import { mouseSelect } from '@/components/mixins/mouseSelect'; import { mouseSelect } from '@/components/mixins/mouseSelect';
import { moveNodeWorkflow } from '@/components/mixins/moveNodeWorkflow'; import { moveNodeWorkflow } from '@/components/mixins/moveNodeWorkflow';
import { restApi } from '@/components/mixins/restApi'; import { restApi } from '@/components/mixins/restApi';
import { globalLinkActions } from '@/components/mixins/globalLinkActions';
import { showMessage } from '@/components/mixins/showMessage'; import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange'; import { titleChange } from '@/components/mixins/titleChange';
import { newVersions } from '@/components/mixins/newVersions'; import { newVersions } from '@/components/mixins/newVersions';
@ -93,6 +163,7 @@ import NodeDetailsView from '@/components/NodeDetailsView.vue';
import Node from '@/components/Node.vue'; import Node from '@/components/Node.vue';
import NodeSettings from '@/components/NodeSettings.vue'; import NodeSettings from '@/components/NodeSettings.vue';
import Sticky from '@/components/Sticky.vue'; import Sticky from '@/components/Sticky.vue';
import CanvasAddButton from './CanvasAddButton.vue';
import * as CanvasHelpers from './canvasHelpers'; import * as CanvasHelpers from './canvasHelpers';
@ -129,6 +200,7 @@ import {
XYPosition, XYPosition,
IPushDataExecutionFinished, IPushDataExecutionFinished,
ITag, ITag,
INewWorkflowData,
IWorkflowTemplate, IWorkflowTemplate,
IExecutionsSummary, IExecutionsSummary,
IWorkflowToShare, IWorkflowToShare,
@ -158,6 +230,7 @@ export default mixins(
workflowHelpers, workflowHelpers,
workflowRun, workflowRun,
newVersions, newVersions,
globalLinkActions,
debounceHelper, debounceHelper,
) )
.extend({ .extend({
@ -168,6 +241,7 @@ export default mixins(
NodeCreator: () => import('@/components/Node/NodeCreator/NodeCreator.vue'), NodeCreator: () => import('@/components/Node/NodeCreator/NodeCreator.vue'),
NodeSettings, NodeSettings,
Sticky, Sticky,
CanvasAddButton,
NodeCreation: () => import('@/components/Node/NodeCreation.vue'), NodeCreation: () => import('@/components/Node/NodeCreation.vue'),
}, },
errorCaptured: (err, vm, info) => { errorCaptured: (err, vm, info) => {
@ -182,7 +256,7 @@ export default mixins(
this.createNodeActive = false; this.createNodeActive = false;
}, },
nodes: { nodes: {
async handler(value, oldValue) { async handler () {
// Load a workflow // Load a workflow
let workflowId = null as string | null; let workflowId = null as string | null;
if (this.$route && this.$route.params.name) { if (this.$route && this.$route.params.name) {
@ -201,9 +275,14 @@ export default mixins(
}, },
deep: true, deep: true,
}, },
containsTrigger(containsTrigger) {
// Re-center CanvasAddButton if there's no triggers
if (containsTrigger === false) this.setRecenteredCanvasAddButtonPosition(this.getNodeViewOffsetPosition);
else this.tryToAddWelcomeSticky();
},
}, },
async beforeRouteLeave(to, from, next) { async beforeRouteLeave(to, from, next) {
this.$store.commit('setSubworkflowExecutionError', null);
const result = this.$store.getters.getStateIsDirty; const result = this.$store.getters.getStateIsDirty;
if (result) { if (result) {
const confirmModal = await this.confirmModal( const confirmModal = await this.confirmModal(
@ -257,6 +336,12 @@ export default mixins(
isDemo(): boolean { isDemo(): boolean {
return this.$route.name === VIEWS.DEMO; return this.$route.name === VIEWS.DEMO;
}, },
isExecutionView(): boolean {
return this.$route.name === VIEWS.EXECUTION;
},
showCanvasAddButton(): boolean {
return this.loadingService === null && !this.containsTrigger && !this.isDemo && !this.isExecutionView;
},
lastSelectedNode(): INodeUi | null { lastSelectedNode(): INodeUi | null {
return this.$store.getters.lastSelectedNode; return this.$store.getters.lastSelectedNode;
}, },
@ -275,14 +360,19 @@ export default mixins(
return this.$locale.baseText('nodeView.runButtonText.executingWorkflow'); return this.$locale.baseText('nodeView.runButtonText.executingWorkflow');
}, },
workflowStyle(): object { workflowStyle(): object {
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition; const offsetPosition = this.getNodeViewOffsetPosition;
return { return {
left: offsetPosition[0] + 'px', left: offsetPosition[0] + 'px',
top: offsetPosition[1] + 'px', top: offsetPosition[1] + 'px',
}; };
}, },
canvasAddButtonStyle(): object {
return {
'pointer-events': this.createNodeActive ? 'none' : 'all',
};
},
backgroundStyle(): object { backgroundStyle(): object {
return CanvasHelpers.getBackgroundStyles(this.nodeViewScale, this.$store.getters.getNodeViewOffsetPosition); return CanvasHelpers.getBackgroundStyles(this.nodeViewScale, this.getNodeViewOffsetPosition);
}, },
workflowClasses() { workflowClasses() {
const returnClasses = []; const returnClasses = [];
@ -305,6 +395,26 @@ export default mixins(
workflowRunning(): boolean { workflowRunning(): boolean {
return this.$store.getters.isActionActive('workflowRunning'); return this.$store.getters.isActionActive('workflowRunning');
}, },
allTriggersDisabled(): boolean {
const disabledTriggerNodes = this.triggerNodes.filter(node => node.disabled);
return disabledTriggerNodes.length === this.triggerNodes.length;
},
triggerNodes(): INodeUi[] {
return this.nodes.filter(node =>
node.type === START_NODE_TYPE ||
this.$store.getters['nodeTypes/isTriggerNode'](node.type),
);
},
containsTrigger(): boolean {
return this.triggerNodes.length > 0;
},
isExecutionDisabled(): boolean {
return !this.containsTrigger || this.allTriggersDisabled;
},
getNodeViewOffsetPosition(): XYPosition {
return this.$store.getters.getNodeViewOffsetPosition;
},
}, },
data() { data() {
return { return {
@ -325,6 +435,9 @@ export default mixins(
dropPrevented: false, dropPrevented: false,
renamingActive: false, renamingActive: false,
showStickyButton: false, showStickyButton: false,
showTriggerMissingTooltip: false,
canvasAddButtonPosition: [1, 1] as XYPosition,
workflowData: null as INewWorkflowData | null,
}; };
}, },
beforeDestroy() { beforeDestroy() {
@ -333,8 +446,12 @@ export default mixins(
// could add up with them registred multiple times // could add up with them registred multiple times
document.removeEventListener('keydown', this.keyDown); document.removeEventListener('keydown', this.keyDown);
document.removeEventListener('keyup', this.keyUp); document.removeEventListener('keyup', this.keyUp);
this.unregisterCustomAction('showNodeCreator');
}, },
methods: { methods: {
showTriggerMissingToltip(isVisible: boolean) {
this.showTriggerMissingTooltip = isVisible;
},
onRunNode(nodeName: string, source: string) { onRunNode(nodeName: string, source: string) {
const node = this.$store.getters.getNodeByName(nodeName); const node = this.$store.getters.getNodeByName(nodeName);
const telemetryPayload = { const telemetryPayload = {
@ -358,6 +475,25 @@ export default mixins(
this.runWorkflow(); this.runWorkflow();
}, },
onRunContainerClick() {
if (this.containsTrigger && !this.allTriggersDisabled) return;
const message = this.containsTrigger && this.allTriggersDisabled
? this.$locale.baseText('nodeView.addOrEnableTriggerNode')
: this.$locale.baseText('nodeView.addATriggerNodeFirst');
this.registerCustomAction('showNodeCreator', () => this.showTriggerCreator('no_trigger_execution_tooltip'));
const notice = this.$showMessage({
type: 'info',
title: this.$locale.baseText('nodeView.cantExecuteNoTrigger'),
message,
duration: 3000,
onClick: () => setTimeout(() => {
// Close the creator panel if user clicked on the link
if(this.createNodeActive) notice.close();
}, 0),
});
},
clearExecutionData() { clearExecutionData() {
this.$store.commit('setWorkflowExecutionData', null); this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues(); this.updateNodesExecutionIssues();
@ -436,6 +572,13 @@ export default mixins(
const saved = await this.saveCurrentWorkflow(); const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData'); if (saved) this.$store.dispatch('settings/fetchPromptsData');
}, },
showTriggerCreator(source: string) {
if(this.createNodeActive) return;
this.$store.commit('nodeCreator/setSelectedType', TRIGGER_NODE_FILTER);
this.$store.commit('nodeCreator/setShowScrim', true);
this.onToggleNodeCreator({ source, createNodeActive: true });
this.$nextTick(() => this.$store.commit('nodeCreator/setShowTabs', false));
},
async openExecution(executionId: string) { async openExecution(executionId: string) {
this.resetWorkspace(); this.resetWorkspace();
@ -560,7 +703,7 @@ export default mixins(
this.$router.replace({ name: VIEWS.NEW_WORKFLOW, query: { templateId } }); this.$router.replace({ name: VIEWS.NEW_WORKFLOW, query: { templateId } });
await this.addNodes(data.workflow.nodes, data.workflow.connections); await this.addNodes(data.workflow.nodes, data.workflow.connections);
await this.$store.dispatch('workflows/getNewWorkflowData', data.name); this.workflowData = await this.$store.dispatch('workflows/getNewWorkflowData', data.name);
this.$nextTick(() => { this.$nextTick(() => {
this.zoomToFit(); this.zoomToFit();
this.$store.commit('setStateDirty', true); this.$store.commit('setStateDirty', true);
@ -999,21 +1142,21 @@ export default mixins(
}, },
resetZoom() { resetZoom() {
const { scale, offset } = CanvasHelpers.scaleReset({ scale: this.nodeViewScale, offset: this.$store.getters.getNodeViewOffsetPosition }); const { scale, offset } = CanvasHelpers.scaleReset({ scale: this.nodeViewScale, offset: this.getNodeViewOffsetPosition });
this.setZoomLevel(scale); this.setZoomLevel(scale);
this.$store.commit('setNodeViewOffsetPosition', { newOffset: offset }); this.$store.commit('setNodeViewOffsetPosition', { newOffset: offset });
}, },
zoomIn() { zoomIn() {
const { scale, offset: [xOffset, yOffset] } = CanvasHelpers.scaleBigger({ scale: this.nodeViewScale, offset: this.$store.getters.getNodeViewOffsetPosition }); const { scale, offset: [xOffset, yOffset] } = CanvasHelpers.scaleBigger({ scale: this.nodeViewScale, offset: this.getNodeViewOffsetPosition });
this.setZoomLevel(scale); this.setZoomLevel(scale);
this.$store.commit('setNodeViewOffsetPosition', { newOffset: [xOffset, yOffset] }); this.$store.commit('setNodeViewOffsetPosition', { newOffset: [xOffset, yOffset] });
}, },
zoomOut() { zoomOut() {
const { scale, offset: [xOffset, yOffset] } = CanvasHelpers.scaleSmaller({ scale: this.nodeViewScale, offset: this.$store.getters.getNodeViewOffsetPosition }); const { scale, offset: [xOffset, yOffset] } = CanvasHelpers.scaleSmaller({ scale: this.nodeViewScale, offset: this.getNodeViewOffsetPosition });
this.setZoomLevel(scale); this.setZoomLevel(scale);
this.$store.commit('setNodeViewOffsetPosition', { newOffset: [xOffset, yOffset] }); this.$store.commit('setNodeViewOffsetPosition', { newOffset: [xOffset, yOffset] });
@ -1021,7 +1164,7 @@ export default mixins(
setZoomLevel(zoomLevel: number) { setZoomLevel(zoomLevel: number) {
this.nodeViewScale = zoomLevel; // important for background this.nodeViewScale = zoomLevel; // important for background
const element = this.instance.getContainer() as HTMLElement; const element = this.$refs.nodeView as HTMLElement;
if (!element) { if (!element) {
return; return;
} }
@ -1039,15 +1182,44 @@ export default mixins(
// @ts-ignore // @ts-ignore
this.instance.setZoom(zoomLevel); this.instance.setZoom(zoomLevel);
}, },
setRecenteredCanvasAddButtonPosition (offset?: XYPosition) {
zoomToFit() { const position = CanvasHelpers.getMidCanvasPosition(this.nodeViewScale, offset || [0, 0]);
position[0] -= CanvasHelpers.PLACEHOLDER_TRIGGER_NODE_SIZE / 2;
position[1] -= CanvasHelpers.PLACEHOLDER_TRIGGER_NODE_SIZE / 2;
this.canvasAddButtonPosition = CanvasHelpers.getNewNodePosition(this.nodes, position);
},
getPlaceholderTriggerNodeUI (): INodeUi {
this.setRecenteredCanvasAddButtonPosition();
return {
id: uuid(),
...CanvasHelpers.DEFAULT_PLACEHOLDER_TRIGGER_BUTTON,
position: this.canvasAddButtonPosition,
};
},
// Extend nodes with placeholder trigger button as NodeUI object
// with the centered position if canvas doesn't contains trigger node
getNodesWithPlaceholderNode(): INodeUi[] {
const nodes = this.$store.getters.allNodes as INodeUi[]; const nodes = this.$store.getters.allNodes as INodeUi[];
const extendedNodes = this.containsTrigger
? nodes
: [this.getPlaceholderTriggerNodeUI(), ...nodes];
return extendedNodes;
},
zoomToFit() {
const nodes = this.getNodesWithPlaceholderNode() as INodeUi[];
if (nodes.length === 0) { // some unknown workflow executions if (nodes.length === 0) { // some unknown workflow executions
return; return;
} }
const { zoomLevel, offset } = CanvasHelpers.getZoomToFit(nodes, !this.isDemo); const { zoomLevel, offset } = CanvasHelpers.getZoomToFit(nodes);
this.setZoomLevel(zoomLevel); this.setZoomLevel(zoomLevel);
this.$store.commit('setNodeViewOffsetPosition', { newOffset: offset }); this.$store.commit('setNodeViewOffsetPosition', { newOffset: offset });
@ -1313,7 +1485,6 @@ export default mixins(
const nodeTypeName = event.dataTransfer.getData('nodeTypeName'); const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
if (nodeTypeName) { if (nodeTypeName) {
const mousePosition = this.getMousePositionWithinNodeView(event); const mousePosition = this.getMousePositionWithinNodeView(event);
const sidebarOffset = this.sidebarMenuCollapsed ? CanvasHelpers.SIDEBAR_WIDTH : CanvasHelpers.SIDEBAR_WIDTH_EXPANDED;
this.addNode(nodeTypeName, { this.addNode(nodeTypeName, {
position: [ position: [
@ -1460,7 +1631,7 @@ export default mixins(
const lastSelectedNode = this.lastSelectedNode; const lastSelectedNode = this.lastSelectedNode;
if (options.position) { if (options.position) {
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, options.position); newNodeData.position = CanvasHelpers.getNewNodePosition(this.getNodesWithPlaceholderNode(), options.position);
} else if (lastSelectedNode) { } else if (lastSelectedNode) {
const lastSelectedConnection = this.lastSelectedConnection; const lastSelectedConnection = this.lastSelectedConnection;
if (lastSelectedConnection) { // set when injecting into a connection if (lastSelectedConnection) { // set when injecting into a connection
@ -1499,8 +1670,14 @@ export default mixins(
); );
} }
} else { } else {
// If no node is active find a free spot // If added node is a trigger and it's the first one added to the canvas
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, this.lastClickPosition); // we place it at canvasAddButtonPosition to replace the canvas add button
const position = this.$store.getters['nodeTypes/isTriggerNode'](nodeTypeName) && !this.containsTrigger
? this.canvasAddButtonPosition
// If no node is active find a free spot
: this.lastClickPosition as XYPosition;
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, position);
} }
@ -1944,48 +2121,41 @@ export default mixins(
}, },
async newWorkflow(): Promise<void> { async newWorkflow(): Promise<void> {
await this.resetWorkspace(); await this.resetWorkspace();
const newWorkflow = await this.$store.dispatch('workflows/getNewWorkflowData'); this.workflowData = await this.$store.dispatch('workflows/getNewWorkflowData');
this.$store.commit('setStateDirty', false); this.$store.commit('setStateDirty', false);
await this.addNodes([{
id: uuid(),
...CanvasHelpers.DEFAULT_START_NODE,
}]);
this.nodeSelectedByName(CanvasHelpers.DEFAULT_START_NODE.name, false);
this.$store.commit('setStateDirty', false);
this.setZoomLevel(1); this.setZoomLevel(1);
this.zoomToFit();
const flagAvailable = window.posthog !== undefined && window.posthog.getFeatureFlag !== undefined;
if (flagAvailable && window.posthog.getFeatureFlag('welcome-note') === 'test') {
setTimeout(() => {
this.$store.commit('setNodeViewOffsetPosition', { newOffset: [0, 0] });
// For novice users (onboardingFlowEnabled == true)
// Inject welcome sticky note and zoom to fit
if (newWorkflow.onboardingFlowEnabled && !this.isReadOnly) {
this.$nextTick(async () => {
await this.addNodes([
{
id: uuid(),
...CanvasHelpers.WELCOME_STICKY_NODE,
parameters: {
// Use parameters from the template but add translated content
...CanvasHelpers.WELCOME_STICKY_NODE.parameters,
content: this.$locale.baseText('onboardingWorkflow.stickyContent'),
},
},
]);
this.zoomToFit();
this.$telemetry.track('welcome note inserted');
});
}
}, 0);
}
}, },
tryToAddWelcomeSticky: once(async function(this: any) {
const newWorkflow = this.workflowData;
const flagAvailable = window.posthog !== undefined && window.posthog.getFeatureFlag !== undefined;
if (flagAvailable && window.posthog.getFeatureFlag('welcome-note') === 'test') {
// For novice users (onboardingFlowEnabled == true)
// Inject welcome sticky note and zoom to fit
if (newWorkflow?.onboardingFlowEnabled && !this.isReadOnly) {
const collisionPadding = CanvasHelpers.GRID_SIZE + CanvasHelpers.NODE_SIZE;
// Position the welcome sticky left to the added trigger node
let position: XYPosition = [...(this.triggerNodes[0].position as XYPosition)];
position[0] -= CanvasHelpers.WELCOME_STICKY_NODE.parameters.width + (CanvasHelpers.GRID_SIZE * 4);
position = CanvasHelpers.getNewNodePosition(this.nodes, position, [collisionPadding, collisionPadding]);
await this.addNodes([{
id: uuid(),
...CanvasHelpers.WELCOME_STICKY_NODE,
parameters: {
// Use parameters from the template but add translated content
...CanvasHelpers.WELCOME_STICKY_NODE.parameters,
content: this.$locale.baseText('onboardingWorkflow.stickyContent'),
},
position,
}]);
this.$telemetry.track('welcome note inserted');
}
}
}),
async initView(): Promise<void> { async initView(): Promise<void> {
if (this.$route.params.action === 'workflowSave') { if (this.$route.params.action === 'workflowSave') {
// In case the workflow got saved we do not have to run init // In case the workflow got saved we do not have to run init
@ -2001,7 +2171,7 @@ export default mixins(
const templateId = this.$route.params.id; const templateId = this.$route.params.id;
await this.openWorkflowTemplate(templateId); await this.openWorkflowTemplate(templateId);
} }
else if (this.$route.name === VIEWS.EXECUTION) { else if (this.isExecutionView) {
// Load an execution // Load an execution
const executionId = this.$route.params.id; const executionId = this.$route.params.id;
await this.openExecution(executionId); await this.openExecution(executionId);
@ -2057,7 +2227,7 @@ export default mixins(
document.addEventListener('keyup', this.keyUp); document.addEventListener('keyup', this.keyUp);
window.addEventListener("beforeunload", (e) => { window.addEventListener("beforeunload", (e) => {
if (this.isDemo) { if (this.isDemo){
return; return;
} }
else if (this.$store.getters.getStateIsDirty === true) { else if (this.$store.getters.getStateIsDirty === true) {
@ -2368,7 +2538,7 @@ export default mixins(
} }
// "requiredNodeTypes" are also defined in cli/commands/run.ts // "requiredNodeTypes" are also defined in cli/commands/run.ts
const requiredNodeTypes = [START_NODE_TYPE]; const requiredNodeTypes: string[] = [];
if (requiredNodeTypes.includes(node.type)) { if (requiredNodeTypes.includes(node.type)) {
// The node is of the required type so check first // The node is of the required type so check first
@ -2921,7 +3091,7 @@ export default mixins(
this.$store.commit('setActiveWorkflows', activeWorkflows); this.$store.commit('setActiveWorkflows', activeWorkflows);
}, },
async loadNodeTypes(): Promise<void> { async loadNodeTypes(): Promise<void> {
this.$store.dispatch('nodeTypes/getNodeTypes'); await this.$store.dispatch('nodeTypes/getNodeTypes');
}, },
async loadCredentialTypes(): Promise<void> { async loadCredentialTypes(): Promise<void> {
await this.$store.dispatch('credentials/fetchCredentialTypes', true); await this.$store.dispatch('credentials/fetchCredentialTypes', true);
@ -3017,6 +3187,11 @@ export default mixins(
}); });
}, },
onToggleNodeCreator({ source, createNodeActive }: { source?: string; createNodeActive: boolean }) { onToggleNodeCreator({ source, createNodeActive }: { source?: string; createNodeActive: boolean }) {
if (createNodeActive === this.createNodeActive) return;
// Default to the trigger tab in node creator if there's no trigger node yet
if (!this.containsTrigger) this.$store.commit('nodeCreator/setSelectedType', TRIGGER_NODE_FILTER);
this.createNodeActive = createNodeActive; this.createNodeActive = createNodeActive;
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source, createNodeActive }); this.$externalHooks().run('nodeView.createNodeActiveChanged', { source, createNodeActive });
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { source, createNodeActive, workflow_id: this.$store.getters.workflowId }); this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { source, createNodeActive, workflow_id: this.$store.getters.workflowId });
@ -3140,7 +3315,7 @@ export default mixins(
color: #444; color: #444;
padding-right: 5px; padding-right: 5px;
&.expanded { &:not(.demo-zoom-menu).expanded {
left: $sidebar-expanded-width + $--zoom-menu-margin; left: $sidebar-expanded-width + $--zoom-menu-margin;
} }
@ -3175,6 +3350,7 @@ export default mixins(
background-color: var(--color-canvas-background); background-color: var(--color-canvas-background);
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
} }
.node-view-wrapper { .node-view-wrapper {
@ -3212,14 +3388,15 @@ export default mixins(
} }
.workflow-execute-wrapper { .workflow-execute-wrapper {
position: fixed; position: absolute;
line-height: 65px; display: flex;
left: calc(50% - 150px); justify-content: center;
bottom: 30px; left: 50%;
width: 300px; transform: translateX(-50%);
text-align: center; bottom: 110px;
width: auto;
>* { > * {
margin-inline-end: 0.625rem; margin-inline-end: 0.625rem;
} }
} }

View file

@ -28,6 +28,7 @@ const MIN_X_TO_SHOW_OUTPUT_LABEL = 90;
const MIN_Y_TO_SHOW_OUTPUT_LABEL = 100; const MIN_Y_TO_SHOW_OUTPUT_LABEL = 100;
export const NODE_SIZE = 100; export const NODE_SIZE = 100;
export const PLACEHOLDER_TRIGGER_NODE_SIZE = 100;
export const DEFAULT_START_POSITION_X = 180; export const DEFAULT_START_POSITION_X = 180;
export const DEFAULT_START_POSITION_Y = 240; export const DEFAULT_START_POSITION_Y = 240;
export const HEADER_HEIGHT = 65; export const HEADER_HEIGHT = 65;
@ -38,6 +39,7 @@ export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE;
const LOOPBACK_MINIMUM = 140; const LOOPBACK_MINIMUM = 140;
export const INPUT_UUID_KEY = '-input'; export const INPUT_UUID_KEY = '-input';
export const OUTPUT_UUID_KEY = '-output'; export const OUTPUT_UUID_KEY = '-output';
export const PLACEHOLDER_BUTTON = 'PlaceholderTriggerButton';
export const DEFAULT_START_NODE = { export const DEFAULT_START_NODE = {
name: 'Start', name: 'Start',
@ -50,13 +52,24 @@ export const DEFAULT_START_NODE = {
parameters: {}, parameters: {},
}; };
export const DEFAULT_PLACEHOLDER_TRIGGER_BUTTON = {
name: 'Choose a Trigger...',
type: PLACEHOLDER_BUTTON,
typeVersion: 1,
position: [],
parameters: {
height: PLACEHOLDER_TRIGGER_NODE_SIZE,
width: PLACEHOLDER_TRIGGER_NODE_SIZE,
},
};
export const WELCOME_STICKY_NODE = { export const WELCOME_STICKY_NODE = {
name: QUICKSTART_NOTE_NAME, name: QUICKSTART_NOTE_NAME,
type: STICKY_NODE_TYPE, type: STICKY_NODE_TYPE,
typeVersion: 1, typeVersion: 1,
position: [ position: [
-260, 0,
200, 0,
] as XYPosition, ] as XYPosition,
parameters: { parameters: {
height: 300, height: 300,
@ -233,8 +246,10 @@ export const getLeftmostTopNode = (nodes: INodeUi[]): INodeUi => {
export const getWorkflowCorners = (nodes: INodeUi[]): IBounds => { export const getWorkflowCorners = (nodes: INodeUi[]): IBounds => {
return nodes.reduce((accu: IBounds, node: INodeUi) => { return nodes.reduce((accu: IBounds, node: INodeUi) => {
const xOffset = node.type === STICKY_NODE_TYPE && isNumber(node.parameters.width) ? node.parameters.width : NODE_SIZE; const hasCustomDimensions = [STICKY_NODE_TYPE, PLACEHOLDER_BUTTON].includes(node.type);
const yOffset = node.type === STICKY_NODE_TYPE && isNumber(node.parameters.height) ? node.parameters.height : NODE_SIZE; const xOffset = hasCustomDimensions && isNumber(node.parameters.width) ? node.parameters.width : NODE_SIZE;
const yOffset = hasCustomDimensions && isNumber(node.parameters.height) ? node.parameters.height : NODE_SIZE;
const x = node.position[0]; const x = node.position[0];
const y = node.position[1]; const y = node.position[1];
@ -429,11 +444,29 @@ const canUsePosition = (position1: XYPosition, position2: XYPosition) => {
return true; return true;
}; };
function closestNumberDivisibleBy(inputNumber: number, divisibleBy: number) {
const quotient = Math.ceil(inputNumber / divisibleBy);
// 1st possible closest number
const inputNumber1 = divisibleBy * quotient;
// 2nd possible closest number
const inputNumber2 = (inputNumber * divisibleBy) > 0
? (divisibleBy * (quotient + 1))
: (divisibleBy * (quotient - 1));
// if true, then inputNumber1 is the required closest number
if (Math.abs(inputNumber - inputNumber1) < Math.abs(inputNumber - inputNumber2)) return inputNumber1;
// else inputNumber2 is the required closest number
return inputNumber2;
}
export const getNewNodePosition = (nodes: INodeUi[], newPosition: XYPosition, movePosition?: XYPosition): XYPosition => { export const getNewNodePosition = (nodes: INodeUi[], newPosition: XYPosition, movePosition?: XYPosition): XYPosition => {
const targetPosition: XYPosition = [...newPosition]; const targetPosition: XYPosition = [...newPosition];
targetPosition[0] = targetPosition[0] - (targetPosition[0] % GRID_SIZE); targetPosition[0] = closestNumberDivisibleBy(targetPosition[0], GRID_SIZE);
targetPosition[1] = targetPosition[1] - (targetPosition[1] % GRID_SIZE); targetPosition[1] = closestNumberDivisibleBy(targetPosition[1], GRID_SIZE);
if (!movePosition) { if (!movePosition) {
movePosition = [40, 40]; movePosition = [40, 40];
@ -478,7 +511,8 @@ export const getRelativePosition = (x: number, y: number, scale: number, offset:
export const getMidCanvasPosition = (scale: number, offset: XYPosition): XYPosition => { export const getMidCanvasPosition = (scale: number, offset: XYPosition): XYPosition => {
const { editorWidth, editorHeight } = getContentDimensions(); const { editorWidth, editorHeight } = getContentDimensions();
return getRelativePosition((editorWidth - SIDEBAR_WIDTH) / 2, (editorHeight - HEADER_HEIGHT) / 2, scale, offset);
return getRelativePosition(editorWidth / 2, (editorHeight - HEADER_HEIGHT) / 2, scale, offset);
}; };
export const getBackgroundStyles = (scale: number, offsetPosition: XYPosition) => { export const getBackgroundStyles = (scale: number, offsetPosition: XYPosition) => {
@ -630,16 +664,14 @@ const getContentDimensions = (): { editorWidth: number, editorHeight: number } =
}; };
}; };
export const getZoomToFit = (nodes: INodeUi[], addComponentPadding = true): {offset: XYPosition, zoomLevel: number} => { export const getZoomToFit = (nodes: INodeUi[], addFooterPadding = true): {offset: XYPosition, zoomLevel: number} => {
const {minX, minY, maxX, maxY} = getWorkflowCorners(nodes); const { minX, minY, maxX, maxY } = getWorkflowCorners(nodes);
const { editorWidth, editorHeight } = getContentDimensions(); const { editorWidth, editorHeight } = getContentDimensions();
const sidebarWidth = addComponentPadding ? SIDEBAR_WIDTH : 0; const footerHeight = addFooterPadding ? 200 : 100;
const headerHeight = addComponentPadding ? HEADER_HEIGHT: 0;
const footerHeight = addComponentPadding ? 200 : 100;
const PADDING = NODE_SIZE * 4; const PADDING = NODE_SIZE * 4;
const diffX = maxX - minX + sidebarWidth + PADDING; const diffX = maxX - minX + PADDING;
const scaleX = editorWidth / diffX; const scaleX = editorWidth / diffX;
const diffY = maxY - minY + PADDING; const diffY = maxY - minY + PADDING;
@ -648,14 +680,14 @@ export const getZoomToFit = (nodes: INodeUi[], addComponentPadding = true): {off
const zoomLevel = Math.min(scaleX, scaleY, 1); const zoomLevel = Math.min(scaleX, scaleY, 1);
let xOffset = (minX * -1) * zoomLevel; // find top right corner let xOffset = (minX * -1) * zoomLevel; // find top right corner
xOffset += (editorWidth - sidebarWidth - (maxX - minX) * zoomLevel) / 2; // add padding to center workflow xOffset += (editorWidth - (maxX - minX) * zoomLevel) / 2; // add padding to center workflow
let yOffset = (minY * -1) * zoomLevel + headerHeight; // find top right corner let yOffset = (minY * -1) * zoomLevel; // find top right corner
yOffset += (editorHeight - headerHeight - (maxY - minY + footerHeight) * zoomLevel) / 2; // add padding to center workflow yOffset += (editorHeight - (maxY - minY + footerHeight) * zoomLevel) / 2; // add padding to center workflow
return { return {
zoomLevel, zoomLevel,
offset: [xOffset, yOffset - headerHeight], offset: [closestNumberDivisibleBy(xOffset, GRID_SIZE), closestNumberDivisibleBy(yOffset, GRID_SIZE)],
}; };
}; };

View file

@ -14,7 +14,7 @@ export class Cron implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'Cron', displayName: 'Cron',
name: 'cron', name: 'cron',
icon: 'fa:calendar', icon: 'fa:clock',
group: ['trigger', 'schedule'], group: ['trigger', 'schedule'],
version: 1, version: 1,
hidden: true, hidden: true,
@ -24,7 +24,7 @@ export class Cron implements INodeType {
'Your cron trigger will now trigger executions on the schedule you have defined.', 'Your cron trigger will now trigger executions on the schedule you have defined.',
defaults: { defaults: {
name: 'Cron', name: 'Cron',
color: '#00FF00', color: '#29a568',
}, },
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [], inputs: [],

View file

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

View file

@ -30,7 +30,7 @@ import _ from 'lodash';
export class EmailReadImap implements INodeType { export class EmailReadImap implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'EmailReadImap', displayName: 'Email Trigger (IMAP)',
name: 'emailReadImap', name: 'emailReadImap',
icon: 'fa:inbox', icon: 'fa:inbox',
group: ['trigger'], group: ['trigger'],
@ -38,7 +38,7 @@ export class EmailReadImap implements INodeType {
description: 'Triggers the workflow when a new email is received', description: 'Triggers the workflow when a new email is received',
eventTriggerDescription: 'Waiting for you to receive an email', eventTriggerDescription: 'Waiting for you to receive an email',
defaults: { defaults: {
name: 'IMAP Email', name: 'Email Trigger',
color: '#44AA22', color: '#44AA22',
}, },
inputs: [], inputs: [],

View file

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

View file

@ -14,7 +14,7 @@ export class ExecuteWorkflow implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'Execute Workflow', displayName: 'Execute Workflow',
name: 'executeWorkflow', name: 'executeWorkflow',
icon: 'fa:network-wired', icon: 'fa:sign-in-alt',
group: ['transform'], group: ['transform'],
version: 1, version: 1,
subtitle: '={{"Workflow: " + $parameter["workflowId"]}}', subtitle: '={{"Workflow: " + $parameter["workflowId"]}}',

View file

@ -0,0 +1,21 @@
{
"node": "n8n-nodes-base.executeWorkflowTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Core Nodes"
],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.n8n-nodes-base.executeWorkflowTrigger/"
}
],
"generic": []
},
"subcategories": {
"Core Nodes": [
"Helpers"
]
}
}

View file

@ -0,0 +1,36 @@
import { IExecuteFunctions } from 'n8n-core';
import { INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow';
export class ExecuteWorkflowTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Execute Workflow Trigger',
name: 'executeWorkflowTrigger',
icon: 'fa:sign-out-alt',
group: ['trigger'],
version: 1,
description: 'Runs the flow when called by the Execute Workflow node from a different workflow',
maxNodes: 1,
defaults: {
name: 'When Called By Another Workflow',
color: '#ff6d5a',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
outputs: ['main'],
properties: [
{
displayName:
'When an execute workflow node calls this workflow, the execution starts here. Any data passed into the \'execute workflow\' node will be output by this node.',
name: 'notice',
type: 'notice',
default: '',
},
],
};
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
return this.prepareOutputData(items);
}
}

View file

@ -24,6 +24,9 @@
}, },
"alias": ["Time", "Scheduler", "Polling"], "alias": ["Time", "Scheduler", "Polling"],
"subcategories": { "subcategories": {
"Core Nodes": ["Flow"] "Core Nodes": [
"Flow",
"Other Trigger Nodes"
]
} }
} }

View file

@ -12,6 +12,9 @@
}, },
"alias": ["Watch", "Monitor"], "alias": ["Watch", "Monitor"],
"subcategories": { "subcategories": {
"Core Nodes": ["Files"] "Core Nodes":[
"Files",
"Other Trigger Nodes"
]
} }
} }

View file

@ -0,0 +1,16 @@
{
"node": "n8n-nodes-base.manualTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Core Nodes"
],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.manualTrigger/"
}
],
"generic": []
}
}

View file

@ -0,0 +1,40 @@
import { ITriggerFunctions } from 'n8n-core';
import { INodeType, INodeTypeDescription, ITriggerResponse } from 'n8n-workflow';
export class ManualTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Manual Trigger',
name: 'manualTrigger',
icon: 'fa:mouse-pointer',
group: ['trigger', 'input'],
version: 1,
description: 'Runs the flow on clicking a button in n8n',
maxNodes: 1,
defaults: {
name: "On clicking 'execute'",
color: '#909298',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],
outputs: ['main'],
properties: [
{
displayName:
'This node is where a manual workflow execution starts. To make one, go back to the canvas and click execute workflow',
name: 'notice',
type: 'notice',
default: '',
},
],
};
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
const manualTriggerFunction = async () => {
this.emit([this.helpers.returnJsonArray([{}])]);
};
return {
manualTriggerFunction,
};
}
}

View file

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

View file

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

View file

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

View file

@ -437,6 +437,7 @@
"dist/nodes/Eventbrite/EventbriteTrigger.node.js", "dist/nodes/Eventbrite/EventbriteTrigger.node.js",
"dist/nodes/ExecuteCommand/ExecuteCommand.node.js", "dist/nodes/ExecuteCommand/ExecuteCommand.node.js",
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow.node.js", "dist/nodes/ExecuteWorkflow/ExecuteWorkflow.node.js",
"dist/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js",
"dist/nodes/Facebook/FacebookGraphApi.node.js", "dist/nodes/Facebook/FacebookGraphApi.node.js",
"dist/nodes/Facebook/FacebookTrigger.node.js", "dist/nodes/Facebook/FacebookTrigger.node.js",
"dist/nodes/Figma/FigmaTrigger.node.js", "dist/nodes/Figma/FigmaTrigger.node.js",
@ -539,6 +540,7 @@
"dist/nodes/Mailjet/Mailjet.node.js", "dist/nodes/Mailjet/Mailjet.node.js",
"dist/nodes/Mailjet/MailjetTrigger.node.js", "dist/nodes/Mailjet/MailjetTrigger.node.js",
"dist/nodes/Mandrill/Mandrill.node.js", "dist/nodes/Mandrill/Mandrill.node.js",
"dist/nodes/ManualTrigger/ManualTrigger.node.js",
"dist/nodes/Markdown/Markdown.node.js", "dist/nodes/Markdown/Markdown.node.js",
"dist/nodes/Marketstack/Marketstack.node.js", "dist/nodes/Marketstack/Marketstack.node.js",
"dist/nodes/Matrix/Matrix.node.js", "dist/nodes/Matrix/Matrix.node.js",

View file

@ -909,11 +909,22 @@ export class Workflow {
} }
} }
// Check if there is the actual "start" node const startingNodeTypes = [
const startNodeType = 'n8n-nodes-base.start'; 'n8n-nodes-base.manualTrigger',
for (const nodeName of nodeNames) { 'n8n-nodes-base.executeWorkflowTrigger',
'n8n-nodes-base.start',
];
const sortedNodeNames = Object.values(this.nodes)
.sort((a, b) => startingNodeTypes.indexOf(a.type) - startingNodeTypes.indexOf(b.type))
.map((n) => n.name);
for (const nodeName of sortedNodeNames) {
node = this.nodes[nodeName]; node = this.nodes[nodeName];
if (node.type === startNodeType) { if (startingNodeTypes.includes(node.type)) {
if (node.disabled === true) {
continue;
}
return node; return node;
} }
} }

View file

@ -1,4 +1,4 @@
import { INode } from './Interfaces'; import type { INode } from './Interfaces';
/** /**
* Class for instantiating an operational error, e.g. a timeout error. * Class for instantiating an operational error, e.g. a timeout error.
@ -17,3 +17,22 @@ export class WorkflowOperationError extends Error {
this.timestamp = Date.now(); this.timestamp = Date.now();
} }
} }
export class SubworkflowOperationError extends WorkflowOperationError {
description = '';
cause: { message: string; stack: string };
constructor(message: string, description: string) {
super(message);
this.name = this.constructor.name;
this.description = description;
this.cause = {
message,
stack: this.stack as string,
};
}
}
export class CliWorkflowOperationError extends SubworkflowOperationError {}