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",
"version": "0.198.0",
"version": "0.198.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "n8n",
"version": "0.198.0",
"version": "0.198.2",
"hasInstallScript": true,
"workspaces": [
"packages/*",
@ -43375,7 +43375,7 @@
},
"packages/cli": {
"name": "n8n",
"version": "0.198.0",
"version": "0.198.2",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@oclif/command": "^1.5.18",
@ -43422,7 +43422,7 @@
"lodash.unset": "^4.5.2",
"mysql2": "~2.3.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-workflow": "~0.120.0",
"nodemailer": "^6.7.1",
@ -45822,7 +45822,7 @@
},
"packages/editor-ui": {
"name": "n8n-editor-ui",
"version": "0.164.0",
"version": "0.164.2",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@codemirror/autocomplete": "^6.1.0",
@ -72169,7 +72169,7 @@
"lodash.unset": "^4.5.2",
"mysql2": "~2.3.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-workflow": "~0.120.0",
"nodemailer": "^6.7.1",

View file

@ -4,7 +4,7 @@
import { promises as fs } from 'fs';
import { Command, flags } from '@oclif/command';
import { BinaryDataManager, UserSettings, PLACEHOLDER_EMPTY_WORKFLOW_ID } from 'n8n-core';
import { INode, LoggerProxy } from 'n8n-workflow';
import { LoggerProxy } from 'n8n-workflow';
import {
ActiveExecutions,
@ -25,6 +25,7 @@ import {
import { getLogger } from '../src/Logger';
import config from '../config';
import { getInstanceOwner } from '../src/UserManagement/UserManagementHelper';
import { findCliWorkflowStart } from '../src/utils';
export class Execute extends Command {
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
await UserSettings.prepareUserSettings();
@ -144,33 +149,14 @@ export class Execute extends Command {
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 {
const startingNode = findCliWorkflowStart(workflowData.nodes);
const user = await getInstanceOwner();
const runData: IWorkflowExecutionDataProcess = {
executionMode: 'cli',
startNodes: [startNode.name],
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
workflowData: workflowData!,
startNodes: [startingNode.name],
workflowData,
userId: user.id,
};
@ -207,6 +193,7 @@ export class Execute extends Command {
logger.error('\nExecution error:');
logger.info('====================================');
logger.error(e.message);
if (e.description) logger.error(e.description);
logger.error(e.stack);
this.exit(1);
}

View file

@ -39,6 +39,7 @@ import {
import config from '../config';
import { User } from '../src/databases/entities/User';
import { getInstanceOwner } from '../src/UserManagement/UserManagementHelper';
import { findCliWorkflowStart } from '../src/utils';
export class ExecuteBatch extends Command {
static description = '\nExecutes multiple workflows once';
@ -613,16 +614,6 @@ export class ExecuteBatch extends Command {
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.
// 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.
@ -659,14 +650,6 @@ export class ExecuteBatch extends Command {
});
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;
// Timeouts execution after 5 minutes.
@ -678,9 +661,11 @@ export class ExecuteBatch extends Command {
}, ExecuteBatch.executionTimeout);
try {
const startingNode = findCliWorkflowStart(workflowData.nodes);
const runData: IWorkflowExecutionDataProcess = {
executionMode: 'cli',
startNodes: [startNode!.name],
startNodes: [startingNode.name],
workflowData,
userId: ExecuteBatch.instanceOwner.id,
};

View file

@ -33,6 +33,7 @@ import {
IWorkflowHooksOptionalParameters,
IWorkflowSettings,
LoggerProxy as Logger,
SubworkflowOperationError,
Workflow,
WorkflowExecuteMode,
WorkflowHooks,
@ -67,6 +68,7 @@ import {
} from './UserManagement/UserManagementHelper';
import { whereClause } from './WorkflowHelpers';
import { IWorkflowErrorData } from './Interfaces';
import { findSubworkflowStart } from './utils';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -748,21 +750,7 @@ export async function getRunData(
): Promise<IWorkflowExecutionDataProcess> {
const mode = 'integrated';
// Find Start-Node
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.`);
}
const startingNode = findSubworkflowStart(workflowData.nodes);
// Always start with empty data if no inputData got supplied
inputData = inputData || [
@ -774,7 +762,7 @@ export async function getRunData(
// Initialize the incoming data
const nodeExecutionStack: IExecuteData[] = [];
nodeExecutionStack.push({
node: startNode,
node: startingNode,
data: {
main: [inputData],
},

View file

@ -361,11 +361,12 @@ export class WorkflowRunnerProcess {
) {
// 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;
if (
this.data.startNodes?.length === 1 &&
Object.keys(this.data.pinData ?? {}).includes(this.data.startNodes[0])
) {
if (this.data.startNodes?.length === 1 && (noPinData || isPinned(this.data.startNodes[0]))) {
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
export interface IWorkflowDb {
id: string;
@ -756,6 +761,13 @@ export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR';
export interface ISubcategoryItemProps {
subcategory: string;
description: string;
icon?: string;
defaults?: INodeParameters;
iconData?: {
type: string;
icon?: string;
fileBuffer?: string;
};
}
export interface INodeItemProps {
@ -876,6 +888,7 @@ export interface IRootState {
instanceId: string;
nodeMetadata: {[nodeName: string]: INodeMetadata};
isNpmAvailable: boolean;
subworkflowExecutionError: Error | null;
}
export interface ICommunityPackageMap {
@ -981,6 +994,15 @@ export type IFakeDoor = {
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 {
settings: IN8nUISettings;
promptsData: IN8nPrompts;

View file

@ -56,7 +56,7 @@ export default mixins(
<style lang="scss">
.main-header {
background-color: var(--color-background-xlight);
height: 65px;
height: $header-height;
width: 100%;
box-sizing: border-box;
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"/>
</span>
<SaveButton
type="secondary"
:saved="!this.isDirty && !this.isNewWorkflow"
:disabled="isWorkflowSaving"
@click="onSaveButtonClick"

View file

@ -60,7 +60,7 @@
<div v-touch:tap="disableNode" class="option" :title="$locale.baseText('node.activateDeactivateNode')">
<font-awesome-icon :icon="nodeDisabledIcon" />
</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" />
</div>
<div v-touch:tap="setNodeActive" class="option touch" :title="$locale.baseText('node.editNode')" v-if="!isReadOnly">
@ -91,7 +91,7 @@
<script lang="ts">
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 { nodeBase } from '@/components/mixins/nodeBase';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
@ -126,6 +126,10 @@ export default mixins(
NodeIcon,
},
computed: {
isDuplicatable(): boolean {
if(!this.nodeType) return true;
return this.nodeType.maxNodes === undefined || this.sameTypeNodes.length < this.nodeType.maxNodes;
},
isScheduledGroup (): boolean {
return this.nodeType?.group.includes('schedule') === true;
},
@ -183,8 +187,11 @@ export default mixins(
return nodes.length === 1;
},
isManualTypeNode (): boolean {
return this.data.type === MANUAL_TRIGGER_NODE_TYPE;
},
isTriggerNode (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('trigger'));
return this.$store.getters['nodeTypes/isTriggerNode'](this.data.type);
},
isTriggerNodeTooltipEmpty () : boolean {
return this.nodeType !== null ? this.nodeType.eventTriggerDescription === '' : false;
@ -198,6 +205,9 @@ export default mixins(
node (): INodeUi | undefined { // same as this.data but reactive..
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 {
return {
'node-box': true,
@ -378,7 +388,7 @@ export default mixins(
},
methods: {
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');

View file

@ -2,13 +2,17 @@
<div>
<div v-if="!createNodeActive" :class="[$style.nodeButtonsWrapper, showStickyButton ? $style.noEvents : '']" @mouseenter="onCreateMenuHoverIn">
<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">
<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>
<node-creator :active="createNodeActive" @nodeTypeSelected="nodeTypeSelected" @closeNodeCreator="closeNodeCreator" />
<node-creator
:active="createNodeActive"
@nodeTypeSelected="nodeTypeSelected"
@closeNodeCreator="closeNodeCreator"
/>
</div>
</template>
@ -120,12 +124,25 @@ export default Vue.extend({
.nodeCreatorButton {
position: fixed;
text-align: center;
top: 80px;
right: 20px;
top: calc(#{$header-height} + var(--spacing-s));
right: var(--spacing-s);
pointer-events: all !important;
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>

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>
<div :class="$style.category">
<span :class="$style.name">
{{ renderCategoryName(categoryName) }}
{{ renderCategoryName(categoryName) }} ({{ nodesCount }})
</span>
<font-awesome-icon
:class="$style.arrow"
@ -13,16 +13,49 @@
</template>
<script lang="ts">
import Vue from 'vue';
import Vue, { PropType } from 'vue';
import camelcase from 'lodash.camelcase';
import { CategoryName } from '@/plugins/i18n';
import { INodeCreateElement, ICategoriesWithNodes } from '@/Interface';
import { NODE_TYPE_COUNT_MAPPER } from '@/constants';
export default Vue.extend({
props: ['item'],
props: {
item: {
type: Object as PropType<INodeCreateElement>,
},
},
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() {
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: {
renderCategoryName(categoryName: CategoryName) {
@ -38,7 +71,7 @@ export default Vue.extend({
<style lang="scss" module>
.category {
font-size: 11px;
font-weight: bold;
font-weight: 700;
letter-spacing: 1px;
line-height: 11px;
padding: 10px 0;
@ -46,6 +79,7 @@ export default Vue.extend({
border-bottom: 1px solid $node-creator-border-color;
display: flex;
text-transform: uppercase;
cursor: pointer;
}
.name {

View file

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

View file

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

View file

@ -2,176 +2,64 @@
<div
class="container"
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">
<SearchBar
v-model="nodeFilter"
:eventBus="searchEventBus"
@keydown.native="nodeFilterKeyDown"
/>
<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"
<trigger-helper-panel
v-if="selectedType === TRIGGER_NODE_FILTER"
:searchItems="searchItems"
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
>
<ItemIterator
:elements="filteredNodeTypes"
:activeIndex="activeIndex"
@selected="selected"
/>
</div>
<NoResults
<type-selector slot="header" />
</trigger-helper-panel>
<categorized-items
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>
</template>
<script lang="ts">
import Vue from 'vue';
import { PropType } from 'vue';
import { externalHooks } from '@/components/mixins/externalHooks';
import mixins from 'vue-typed-mixins';
import ItemIterator from './ItemIterator.vue';
import NoResults from './NoResults.vue';
import SearchBar from './SearchBar.vue';
import SubcategoryPanel from './SubcategoryPanel.vue';
import { INodeCreateElement, INodeItemProps, ISubcategoryItemProps } 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';
import TriggerHelperPanel from './TriggerHelperPanel.vue';
import { ALL_NODE_FILTER, TRIGGER_NODE_FILTER, OTHER_TRIGGER_NODES_SUBCATEGORY, CORE_NODES_CATEGORY } from '@/constants';
import CategorizedItems from './CategorizedItems.vue';
import TypeSelector from './TypeSelector.vue';
import { INodeCreateElement } from '@/Interface';
export default mixins(externalHooks).extend({
name: 'NodeCreateList',
components: {
ItemIterator,
NoResults,
SubcategoryPanel,
SlideTransition,
SearchBar,
TriggerHelperPanel,
CategorizedItems,
TypeSelector,
},
props: {
searchItems: {
type: Array as PropType<INodeCreateElement[] | null>,
},
},
props: ['categorizedItems', 'categoriesWithNodes', 'searchItems'],
data() {
return {
activeCategory: [] as string[],
activeSubcategory: null as INodeCreateElement | null,
activeIndex: 1,
activeSubcategoryIndex: 0,
nodeFilter: '',
selectedType: ALL_NODE_FILTER,
searchEventBus: new Vue(),
REGULAR_NODE_FILTER,
CORE_NODES_CATEGORY,
TRIGGER_NODE_FILTER,
ALL_NODE_FILTER,
OTHER_TRIGGER_NODES_SUBCATEGORY,
};
},
computed: {
searchFilter(): string {
return this.nodeFilter.toLowerCase().trim();
},
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));
selectedType(): string {
return this.$store.getters['nodeCreator/selectedType'];
},
},
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) {
this.$externalHooks().run('nodeCreateList.selectedTypeChanged', {
oldValue,
@ -184,170 +72,23 @@ export default mixins(externalHooks).extend({
});
},
},
methods: {
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];
});
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.$telemetry.trackNodesPanel('nodeCreateList.destroyed', { workflow_id: this.$store.getters.workflowId });
},
});
</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;
}
.container {
height: 100%;
> div {
}
.main-panel {
height: 100%;
}
}
.main-panel .scrollable {
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>

View file

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

View file

@ -1,5 +1,8 @@
<template>
<SlideTransition>
<div>
<aside :class="{'node-creator-scrim': true, expanded: !sidebarMenuCollapsed, active: showScrim}" />
<slide-transition>
<div
v-if="active"
class="node-creator"
@ -8,27 +11,24 @@
@dragover="onDragOver"
@drop="onDrop"
>
<MainPanel
<main-panel
@nodeTypeSelected="nodeTypeSelected"
:categorizedItems="categorizedItems"
:categoriesWithNodes="categoriesWithNodes"
:searchItems="searchItems"
/>
</div>
</SlideTransition>
</slide-transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { ICategoriesWithNodes, INodeCreateElement } from '@/Interface';
import { INodeCreateElement } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import SlideTransition from '../../transitions/SlideTransition.vue';
import MainPanel from './MainPanel.vue';
import { getCategoriesWithNodes, getCategorizedList } from './helpers';
import { mapGetters } from 'vuex';
export default Vue.extend({
name: 'NodeCreator',
@ -42,18 +42,14 @@ export default Vue.extend({
},
},
computed: {
...mapGetters('users', ['personalizedNodeTypes']),
allLatestNodeTypes(): INodeTypeDescription[] {
return this.$store.getters['nodeTypes/allLatestNodeTypes'];
showScrim(): boolean {
return this.$store.getters['nodeCreator/showScrim'];
},
sidebarMenuCollapsed(): boolean {
return this.$store.getters['ui/sidebarMenuCollapsed'];
},
visibleNodeTypes(): INodeTypeDescription[] {
return this.allLatestNodeTypes.filter((nodeType) => !nodeType.hidden);
},
categoriesWithNodes(): ICategoriesWithNodes {
return getCategoriesWithNodes(this.visibleNodeTypes, this.personalizedNodeTypes as string[]);
},
categorizedItems(): INodeCreateElement[] {
return getCategorizedList(this.categoriesWithNodes);
return this.$store.getters['nodeTypes/visibleNodeTypes'];
},
searchItems(): INodeCreateElement[] {
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>
@ -113,20 +114,31 @@ export default Vue.extend({
.node-creator {
position: fixed;
top: $header-height;
bottom: 0;
right: 0;
width: $node-creator-width;
height: 100%;
background-color: $node-creator-background-color;
z-index: 200;
width: $node-creator-width;
color: $node-creator-text-color;
}
&:before {
box-sizing: border-box;
content: ' ';
border-left: 1px solid $node-creator-border-color;
width: 1px;
position: absolute;
height: 100%;
.node-creator-scrim {
position: fixed;
top: $header-height;
right: 0;
bottom: 0;
left: $sidebar-width;
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>

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@
</template>
<script lang="ts">
import { WEBHOOK_NODE_TYPE } from '@/constants';
import { WEBHOOK_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
import { INodeUi } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
@ -72,7 +72,10 @@ export default mixins(
return this.$store.getters.isActionActive('workflowRunning');
},
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 {
return !!(this.nodeType && this.nodeType.polling);
@ -138,7 +141,7 @@ export default mixins(
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');
}

View file

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

View file

@ -158,6 +158,10 @@
</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">
<slot name="node-not-run"></slot>
</div>
@ -499,7 +503,7 @@ export default mixins(
return null;
},
isTriggerNode (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('trigger'));
return this.$store.getters['nodeTypes/isTriggerNode'](this.node.type);
},
canPinData (): boolean {
return !this.isPaneTypeInput &&
@ -522,6 +526,12 @@ export default mixins(
hasNodeRun(): boolean {
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 {
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"
:disabled="disabled"
:class="$style.button"
:type="type"
@click="$emit('click')"
/>
</span>
@ -36,6 +37,10 @@ export default Vue.extend({
savedLabel: {
type: String,
},
type: {
type: String,
default: 'primary',
},
},
computed: {
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 { isResourceLocatorValue } from '@/typeGuards';
import dateformat from 'dateformat';
@ -49,10 +49,7 @@ export function getTriggerNodeServiceName(nodeType: INodeTypeDescription): strin
}
export function getActivatableTriggerNodes(nodes: INodeUi[]) {
return nodes.filter((node: INodeUi) => {
// 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;
});
return nodes.filter((node: INodeUi) => !node.disabled && !NON_ACTIVATABLE_TRIGGER_NODE_TYPES.includes(node.type));
}
export function filterTemplateNodes(nodes: ITemplatesNode[]) {

View file

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

View file

@ -29,13 +29,31 @@ export const nodeBase = mixins(
return this.data.id;
},
},
props: [
'name',
'instance',
'isReadOnly',
'isActive',
'hideActions',
],
props: {
name: {
type: String,
},
instance: {
// 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: {
__addInputEndpoints (node: INodeUi, nodeTypeData: INodeTypeDescription) {
// Add Inputs

View file

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

View file

@ -1,6 +1,6 @@
<template>
<transition name="slide">
<slot></slot>
<slot />
</transition>
</template>
@ -21,4 +21,5 @@ export default Vue.extend({
.slide-enter {
transform: translateX(100%);
}
</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_TRIGGER_NODE_TYPE = 'n8n-nodes-base.jiraTrigger';
export const MICROSOFT_EXCEL_NODE_TYPE = 'n8n-nodes-base.microsoftExcel';
export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger';
export const MICROSOFT_TEAMS_NODE_TYPE = 'n8n-nodes-base.microsoftTeams';
export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp';
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 WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook';
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 XERO_NODE_TYPE = 'n8n-nodes-base.xero';
export const ZENDESK_NODE_TYPE = 'n8n-nodes-base.zendesk';
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 = [
IF_NODE_TYPE,
SWITCH_NODE_TYPE,
@ -127,6 +136,7 @@ export const PIN_DATA_NODE_TYPES_DENYLIST = [
// Node creator
export const CORE_NODES_CATEGORY = 'Core Nodes';
export const COMMUNICATION_CATEGORY = 'Communication';
export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
export const SUBCATEGORY_DESCRIPTIONS: {
[category: string]: { [subcategory: string]: string };
@ -144,6 +154,7 @@ export const ALL_NODE_FILTER = 'All';
export const UNCATEGORIZED_CATEGORY = 'Miscellaneous';
export const UNCATEGORIZED_SUBCATEGORY = 'Helpers';
export const PERSONALIZED_CATEGORY = 'Suggested Nodes';
export const OTHER_TRIGGER_NODES_SUBCATEGORY = 'Other Trigger Nodes';
export const 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
`;
export const NODE_TYPE_COUNT_MAPPER = {
[REGULAR_NODE_FILTER]: ['regularCount'],
[TRIGGER_NODE_FILTER]: ['triggerCount'],
[ALL_NODE_FILTER]: ['triggerCount', 'regularCount'],
};
export const TEMPLATES_NODES_FILTER = [
'n8n-nodes-base.start',
'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,
} from '@/api/nodeTypes';
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> = {
namespaced: true,
@ -55,6 +56,19 @@ const module: Module<INodeTypesState, IRootState> = {
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: {
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,
FAKE_DOOR_FEATURES,
COMMUNITY_PACKAGE_MANAGE_ACTIONS,
ALL_NODE_FILTER,
IMPORT_CURL_MODAL_KEY,
} from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
IExecutionResponse,
IFakeDoor,
IFakeDoorLocation,
IRootState,
IRunDataDisplayMode,
IUiState,
INodeFilterType,
XYPosition,
} from '../Interface';
@ -329,6 +329,9 @@ const module: Module<IUiState, IRootState> = {
setOutputPanelEditModeValue: (state: IUiState, payload: string) => {
Vue.set(state.ndv.output.editMode, 'value', payload);
},
setMainPanelRelativePosition(state: IUiState, relativePosition: number) {
state.mainPanelPosition = relativePosition;
},
setMappableNDVInputFocus(state: IUiState, paramName: string) {
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-no-results-background-color: var(--color-background-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-hover: var(--color-text-base);
$node-creator-search-placeholder-color: var(--color-text-light);

View file

@ -636,16 +636,31 @@
"nodeCreator.noResults.requestTheNode": "Request the node",
"nodeCreator.noResults.wantUsToMakeItFaster": "Want us to make it faster?",
"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.searchBar.searchNodes": "Search nodes...",
"nodeCreator.subcategoryDescriptions.appTriggerNodes": "Runs the flow when something happens in an app like Telegram, Notion or Airtable",
"nodeCreator.subcategoryDescriptions.dataTransformation": "Manipulate data fields, run code",
"nodeCreator.subcategoryDescriptions.files": "Work with CSV, XML, text, images etc.",
"nodeCreator.subcategoryDescriptions.flow": "Branches, core triggers, merge data",
"nodeCreator.subcategoryDescriptions.helpers": "HTTP Requests (API calls), date and time, scrape HTML",
"nodeCreator.subcategoryDescriptions.otherTriggerNodes": "Runs the flow on workflow errors, file changes, and more",
"nodeCreator.subcategoryNames.appTriggerNodes": "On App Event",
"nodeCreator.subcategoryNames.dataTransformation": "Data Transformation",
"nodeCreator.subcategoryNames.files": "Files",
"nodeCreator.subcategoryNames.flow": "Flow",
"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.credentialFor": "Credential for {credentialType}",
"nodeCredentials.issues": "Issues",
@ -702,14 +717,19 @@
"nodeSettings.waitBetweenTries.description": "How long to wait between each attempt (in milliseconds)",
"nodeSettings.waitBetweenTries.displayName": "Wait Between Tries (ms)",
"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.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.confirmButtonText": "Yes, import",
"nodeView.confirmMessage.receivedCopyPasteData.headline": "Import Workflow?",
"nodeView.confirmMessage.receivedCopyPasteData.message": "Workflow will be imported from<br /><i>{plainTextData}<i>",
"nodeView.couldntImportWorkflow": "Could not import workflow",
"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.loadingTemplate": "Loading template",
"nodeView.moreInfo": "More info",
@ -736,10 +756,10 @@
"nodeView.showError.stopExecution.title": "Problem stopping execution",
"nodeView.showError.stopWaitingForWebhook.title": "Problem deleting test webhook",
"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.showMaxNodeTypeError.message": "Only {count} '{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.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 insert node",
"nodeView.showMessage.stopExecutionCatch.message": "It completed before it could be stopped",
"nodeView.showMessage.stopExecutionCatch.title": "Workflow finished executing",
"nodeView.showMessage.stopExecutionTry.title": "Execution stopped",

View file

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

View file

@ -46,6 +46,7 @@ import templates from './modules/templates';
import {stringSizeInBytes} from "@/components/helpers";
import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus";
import communityNodes from './modules/communityNodes';
import nodeCreator from './modules/nodeCreator';
import { isJsonKeyObject } from './utils';
import { getPairedItemsMapping } from './pairedItemUtils';
@ -102,6 +103,7 @@ const state: IRootState = {
sidebarMenuItems: [],
instanceId: '',
nodeMetadata: {},
subworkflowExecutionError: null,
};
const modules = {
@ -115,6 +117,7 @@ const modules = {
users,
ui,
communityNodes,
nodeCreator,
};
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, 'stoppedAt', finishedActiveExecution.data.stoppedAt);
},
setSubworkflowExecutionError(state, subworkflowExecutionError: Error | null) {
state.subworkflowExecutionError = subworkflowExecutionError;
},
setActiveExecutions(state, newActiveExecutions: IExecutionsCurrentSummaryExtended[]) {
Vue.set(state, 'activeExecutions', newActiveExecutions);
},
@ -724,6 +730,10 @@ export const store = new Vuex.Store({
return state.activeCredentialType;
},
subworkflowExecutionError: (state): Error | null => {
return state.subworkflowExecutionError;
},
isActionActive: (state) => (action: string): boolean => {
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"
@wheel="wheelScroll">
<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">
<node v-if="nodeData.type !== STICKY_NODE_TYPE" @duplicateNode="duplicateNode"
@deselectAllNodes="deselectAllNodes" @deselectNode="nodeDeselectedByName" @nodeSelected="nodeSelectedByName"
@removeNode="removeNode" @runWorkflow="onRunNode" @moved="onNodeMoved" @run="onNodeRun" :key="nodeData.id"
:name="nodeData.name" :isReadOnly="isReadOnly" :instance="instance"
:isActive="!!activeNode && activeNode.name === nodeData.name" :hideActions="pullConnActive" />
<Sticky v-else @deselectAllNodes="deselectAllNodes" @deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName" @removeNode="removeNode" :key="nodeData.id" :name="nodeData.name"
:isReadOnly="isReadOnly" :instance="instance" :isActive="!!activeNode && activeNode.name === nodeData.name"
:nodeViewScale="nodeViewScale" :gridSize="GRID_SIZE" :hideActions="pullConnActive" />
<node
v-if="nodeData.type !== STICKY_NODE_TYPE"
@duplicateNode="duplicateNode"
@deselectAllNodes="deselectAllNodes"
@deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName"
@removeNode="removeNode"
@runWorkflow="onRunNode"
@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>
<NodeDetailsView :readOnly="isReadOnly" :renaming="renamingActive" @valueChanged="valueChanged" />
<node-creation v-if="!isReadOnly" :create-node-active="createNodeActive" :node-view-scale="nodeViewScale" @toggleNodeCreator="onToggleNodeCreator" @addNode="onAddNode"/>
<node-details-view
:readOnly="isReadOnly"
:renaming="renamingActive"
@valueChanged="valueChanged"
/>
<node-creation
v-if="!isReadOnly"
:create-node-active="createNodeActive"
:node-view-scale="nodeViewScale"
@toggleNodeCreator="onToggleNodeCreator"
@addNode="onAddNode"
/>
<div
: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')"
@ -31,10 +82,25 @@
<n8n-icon-button v-if="nodeViewScale !== 1 && !isDemo" @click="resetZoom" type="tertiary" size="large"
:title="$locale.baseText('nodeView.resetZoom')" icon="undo" />
</div>
<div class="workflow-execute-wrapper" v-if="!isReadOnly">
<n8n-button @click.stop="onRunWorkflow" :loading="workflowRunning" :label="runButtonText"
:title="$locale.baseText('nodeView.executesTheWorkflowFromTheStartOrWebhookNode')" size="large"
icon="play-circle" type="primary" />
<div
class="workflow-execute-wrapper" v-if="!isReadOnly"
>
<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"
class="stop-execution" type="secondary" :title="stopExecutionInProgress
@ -46,7 +112,7 @@
icon="stop" size="large" :title="$locale.baseText('nodeView.stopWaitingForWebhookCall')" type="secondary"
@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"
@click.stop="clearExecutionData()" />
</div>
@ -60,6 +126,8 @@ import {
} from 'jsplumb';
import type { MessageBoxInputData } from 'element-ui/types/message-box';
import { jsPlumb, OnConnectionBindInfo } from 'jsplumb';
import once from 'lodash/once';
import {
FIRST_ONBOARDING_PROMPT_TIMEOUT,
MODAL_CANCEL,
@ -75,6 +143,7 @@ import {
VIEWS,
WEBHOOK_NODE_TYPE,
WORKFLOW_OPEN_MODAL_KEY,
TRIGGER_NODE_FILTER,
} from '@/constants';
import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from '@/components/mixins/externalHooks';
@ -82,6 +151,7 @@ import { genericHelpers } from '@/components/mixins/genericHelpers';
import { mouseSelect } from '@/components/mixins/mouseSelect';
import { moveNodeWorkflow } from '@/components/mixins/moveNodeWorkflow';
import { restApi } from '@/components/mixins/restApi';
import { globalLinkActions } from '@/components/mixins/globalLinkActions';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { newVersions } from '@/components/mixins/newVersions';
@ -93,6 +163,7 @@ import NodeDetailsView from '@/components/NodeDetailsView.vue';
import Node from '@/components/Node.vue';
import NodeSettings from '@/components/NodeSettings.vue';
import Sticky from '@/components/Sticky.vue';
import CanvasAddButton from './CanvasAddButton.vue';
import * as CanvasHelpers from './canvasHelpers';
@ -129,6 +200,7 @@ import {
XYPosition,
IPushDataExecutionFinished,
ITag,
INewWorkflowData,
IWorkflowTemplate,
IExecutionsSummary,
IWorkflowToShare,
@ -158,6 +230,7 @@ export default mixins(
workflowHelpers,
workflowRun,
newVersions,
globalLinkActions,
debounceHelper,
)
.extend({
@ -168,6 +241,7 @@ export default mixins(
NodeCreator: () => import('@/components/Node/NodeCreator/NodeCreator.vue'),
NodeSettings,
Sticky,
CanvasAddButton,
NodeCreation: () => import('@/components/Node/NodeCreation.vue'),
},
errorCaptured: (err, vm, info) => {
@ -182,7 +256,7 @@ export default mixins(
this.createNodeActive = false;
},
nodes: {
async handler(value, oldValue) {
async handler () {
// Load a workflow
let workflowId = null as string | null;
if (this.$route && this.$route.params.name) {
@ -201,9 +275,14 @@ export default mixins(
},
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) {
this.$store.commit('setSubworkflowExecutionError', null);
const result = this.$store.getters.getStateIsDirty;
if (result) {
const confirmModal = await this.confirmModal(
@ -257,6 +336,12 @@ export default mixins(
isDemo(): boolean {
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 {
return this.$store.getters.lastSelectedNode;
},
@ -275,14 +360,19 @@ export default mixins(
return this.$locale.baseText('nodeView.runButtonText.executingWorkflow');
},
workflowStyle(): object {
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
const offsetPosition = this.getNodeViewOffsetPosition;
return {
left: offsetPosition[0] + 'px',
top: offsetPosition[1] + 'px',
};
},
canvasAddButtonStyle(): object {
return {
'pointer-events': this.createNodeActive ? 'none' : 'all',
};
},
backgroundStyle(): object {
return CanvasHelpers.getBackgroundStyles(this.nodeViewScale, this.$store.getters.getNodeViewOffsetPosition);
return CanvasHelpers.getBackgroundStyles(this.nodeViewScale, this.getNodeViewOffsetPosition);
},
workflowClasses() {
const returnClasses = [];
@ -305,6 +395,26 @@ export default mixins(
workflowRunning(): boolean {
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() {
return {
@ -325,6 +435,9 @@ export default mixins(
dropPrevented: false,
renamingActive: false,
showStickyButton: false,
showTriggerMissingTooltip: false,
canvasAddButtonPosition: [1, 1] as XYPosition,
workflowData: null as INewWorkflowData | null,
};
},
beforeDestroy() {
@ -333,8 +446,12 @@ export default mixins(
// could add up with them registred multiple times
document.removeEventListener('keydown', this.keyDown);
document.removeEventListener('keyup', this.keyUp);
this.unregisterCustomAction('showNodeCreator');
},
methods: {
showTriggerMissingToltip(isVisible: boolean) {
this.showTriggerMissingTooltip = isVisible;
},
onRunNode(nodeName: string, source: string) {
const node = this.$store.getters.getNodeByName(nodeName);
const telemetryPayload = {
@ -358,6 +475,25 @@ export default mixins(
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() {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
@ -436,6 +572,13 @@ export default mixins(
const saved = await this.saveCurrentWorkflow();
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) {
this.resetWorkspace();
@ -560,7 +703,7 @@ export default mixins(
this.$router.replace({ name: VIEWS.NEW_WORKFLOW, query: { templateId } });
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.zoomToFit();
this.$store.commit('setStateDirty', true);
@ -999,21 +1142,21 @@ export default mixins(
},
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.$store.commit('setNodeViewOffsetPosition', { newOffset: offset });
},
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.$store.commit('setNodeViewOffsetPosition', { newOffset: [xOffset, yOffset] });
},
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.$store.commit('setNodeViewOffsetPosition', { newOffset: [xOffset, yOffset] });
@ -1021,7 +1164,7 @@ export default mixins(
setZoomLevel(zoomLevel: number) {
this.nodeViewScale = zoomLevel; // important for background
const element = this.instance.getContainer() as HTMLElement;
const element = this.$refs.nodeView as HTMLElement;
if (!element) {
return;
}
@ -1039,15 +1182,44 @@ export default mixins(
// @ts-ignore
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 extendedNodes = this.containsTrigger
? nodes
: [this.getPlaceholderTriggerNodeUI(), ...nodes];
return extendedNodes;
},
zoomToFit() {
const nodes = this.getNodesWithPlaceholderNode() as INodeUi[];
if (nodes.length === 0) { // some unknown workflow executions
return;
}
const { zoomLevel, offset } = CanvasHelpers.getZoomToFit(nodes, !this.isDemo);
const { zoomLevel, offset } = CanvasHelpers.getZoomToFit(nodes);
this.setZoomLevel(zoomLevel);
this.$store.commit('setNodeViewOffsetPosition', { newOffset: offset });
@ -1313,7 +1485,6 @@ export default mixins(
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
if (nodeTypeName) {
const mousePosition = this.getMousePositionWithinNodeView(event);
const sidebarOffset = this.sidebarMenuCollapsed ? CanvasHelpers.SIDEBAR_WIDTH : CanvasHelpers.SIDEBAR_WIDTH_EXPANDED;
this.addNode(nodeTypeName, {
position: [
@ -1460,7 +1631,7 @@ export default mixins(
const lastSelectedNode = this.lastSelectedNode;
if (options.position) {
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, options.position);
newNodeData.position = CanvasHelpers.getNewNodePosition(this.getNodesWithPlaceholderNode(), options.position);
} else if (lastSelectedNode) {
const lastSelectedConnection = this.lastSelectedConnection;
if (lastSelectedConnection) { // set when injecting into a connection
@ -1499,8 +1670,14 @@ export default mixins(
);
}
} else {
// If added node is a trigger and it's the first one added to the canvas
// 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
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, this.lastClickPosition);
: this.lastClickPosition as XYPosition;
newNodeData.position = CanvasHelpers.getNewNodePosition(this.nodes, position);
}
@ -1944,32 +2121,28 @@ export default mixins(
},
async newWorkflow(): Promise<void> {
await this.resetWorkspace();
const newWorkflow = await this.$store.dispatch('workflows/getNewWorkflowData');
this.workflowData = await this.$store.dispatch('workflows/getNewWorkflowData');
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.zoomToFit();
},
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') {
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([
{
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: {
@ -1977,15 +2150,12 @@ export default mixins(
...CanvasHelpers.WELCOME_STICKY_NODE.parameters,
content: this.$locale.baseText('onboardingWorkflow.stickyContent'),
},
},
]);
this.zoomToFit();
position,
}]);
this.$telemetry.track('welcome note inserted');
});
}
}, 0);
}
},
}),
async initView(): Promise<void> {
if (this.$route.params.action === 'workflowSave') {
// 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;
await this.openWorkflowTemplate(templateId);
}
else if (this.$route.name === VIEWS.EXECUTION) {
else if (this.isExecutionView) {
// Load an execution
const executionId = this.$route.params.id;
await this.openExecution(executionId);
@ -2368,7 +2538,7 @@ export default mixins(
}
// "requiredNodeTypes" are also defined in cli/commands/run.ts
const requiredNodeTypes = [START_NODE_TYPE];
const requiredNodeTypes: string[] = [];
if (requiredNodeTypes.includes(node.type)) {
// The node is of the required type so check first
@ -2921,7 +3091,7 @@ export default mixins(
this.$store.commit('setActiveWorkflows', activeWorkflows);
},
async loadNodeTypes(): Promise<void> {
this.$store.dispatch('nodeTypes/getNodeTypes');
await this.$store.dispatch('nodeTypes/getNodeTypes');
},
async loadCredentialTypes(): Promise<void> {
await this.$store.dispatch('credentials/fetchCredentialTypes', true);
@ -3017,6 +3187,11 @@ export default mixins(
});
},
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.$externalHooks().run('nodeView.createNodeActiveChanged', { source, createNodeActive });
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { source, createNodeActive, workflow_id: this.$store.getters.workflowId });
@ -3140,7 +3315,7 @@ export default mixins(
color: #444;
padding-right: 5px;
&.expanded {
&:not(.demo-zoom-menu).expanded {
left: $sidebar-expanded-width + $--zoom-menu-margin;
}
@ -3175,6 +3350,7 @@ export default mixins(
background-color: var(--color-canvas-background);
width: 100%;
height: 100%;
position: relative;
}
.node-view-wrapper {
@ -3212,12 +3388,13 @@ export default mixins(
}
.workflow-execute-wrapper {
position: fixed;
line-height: 65px;
left: calc(50% - 150px);
bottom: 30px;
width: 300px;
text-align: center;
position: absolute;
display: flex;
justify-content: center;
left: 50%;
transform: translateX(-50%);
bottom: 110px;
width: auto;
> * {
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;
export const NODE_SIZE = 100;
export const PLACEHOLDER_TRIGGER_NODE_SIZE = 100;
export const DEFAULT_START_POSITION_X = 180;
export const DEFAULT_START_POSITION_Y = 240;
export const HEADER_HEIGHT = 65;
@ -38,6 +39,7 @@ export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE;
const LOOPBACK_MINIMUM = 140;
export const INPUT_UUID_KEY = '-input';
export const OUTPUT_UUID_KEY = '-output';
export const PLACEHOLDER_BUTTON = 'PlaceholderTriggerButton';
export const DEFAULT_START_NODE = {
name: 'Start',
@ -50,13 +52,24 @@ export const DEFAULT_START_NODE = {
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 = {
name: QUICKSTART_NOTE_NAME,
type: STICKY_NODE_TYPE,
typeVersion: 1,
position: [
-260,
200,
0,
0,
] as XYPosition,
parameters: {
height: 300,
@ -233,8 +246,10 @@ export const getLeftmostTopNode = (nodes: INodeUi[]): INodeUi => {
export const getWorkflowCorners = (nodes: INodeUi[]): IBounds => {
return nodes.reduce((accu: IBounds, node: INodeUi) => {
const xOffset = node.type === STICKY_NODE_TYPE && isNumber(node.parameters.width) ? node.parameters.width : NODE_SIZE;
const yOffset = node.type === STICKY_NODE_TYPE && isNumber(node.parameters.height) ? node.parameters.height : NODE_SIZE;
const hasCustomDimensions = [STICKY_NODE_TYPE, PLACEHOLDER_BUTTON].includes(node.type);
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 y = node.position[1];
@ -429,11 +444,29 @@ const canUsePosition = (position1: XYPosition, position2: XYPosition) => {
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 => {
const targetPosition: XYPosition = [...newPosition];
targetPosition[0] = targetPosition[0] - (targetPosition[0] % GRID_SIZE);
targetPosition[1] = targetPosition[1] - (targetPosition[1] % GRID_SIZE);
targetPosition[0] = closestNumberDivisibleBy(targetPosition[0], GRID_SIZE);
targetPosition[1] = closestNumberDivisibleBy(targetPosition[1], GRID_SIZE);
if (!movePosition) {
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 => {
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) => {
@ -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 { editorWidth, editorHeight } = getContentDimensions();
const sidebarWidth = addComponentPadding ? SIDEBAR_WIDTH : 0;
const headerHeight = addComponentPadding ? HEADER_HEIGHT: 0;
const footerHeight = addComponentPadding ? 200 : 100;
const footerHeight = addFooterPadding ? 200 : 100;
const PADDING = NODE_SIZE * 4;
const diffX = maxX - minX + sidebarWidth + PADDING;
const diffX = maxX - minX + PADDING;
const scaleX = editorWidth / diffX;
const diffY = maxY - minY + PADDING;
@ -648,14 +680,14 @@ export const getZoomToFit = (nodes: INodeUi[], addComponentPadding = true): {off
const zoomLevel = Math.min(scaleX, scaleY, 1);
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
yOffset += (editorHeight - headerHeight - (maxY - minY + footerHeight) * zoomLevel) / 2; // add padding to center workflow
let yOffset = (minY * -1) * zoomLevel; // find top right corner
yOffset += (editorHeight - (maxY - minY + footerHeight) * zoomLevel) / 2; // add padding to center workflow
return {
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 = {
displayName: 'Cron',
name: 'cron',
icon: 'fa:calendar',
icon: 'fa:clock',
group: ['trigger', 'schedule'],
version: 1,
hidden: true,
@ -24,7 +24,7 @@ export class Cron implements INodeType {
'Your cron trigger will now trigger executions on the schedule you have defined.',
defaults: {
name: 'Cron',
color: '#00FF00',
color: '#29a568',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
inputs: [],

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ export class ExecuteWorkflow implements INodeType {
description: INodeTypeDescription = {
displayName: 'Execute Workflow',
name: 'executeWorkflow',
icon: 'fa:network-wired',
icon: 'fa:sign-in-alt',
group: ['transform'],
version: 1,
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"],
"subcategories": {
"Core Nodes": ["Flow"]
"Core Nodes": [
"Flow",
"Other Trigger Nodes"
]
}
}

View file

@ -12,6 +12,9 @@
},
"alias": ["Watch", "Monitor"],
"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": {
"Core Nodes": ["Flow"]
"Core Nodes": [
"Flow",
"Other Trigger Nodes"
]
}
}

View file

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

View file

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

View file

@ -437,6 +437,7 @@
"dist/nodes/Eventbrite/EventbriteTrigger.node.js",
"dist/nodes/ExecuteCommand/ExecuteCommand.node.js",
"dist/nodes/ExecuteWorkflow/ExecuteWorkflow.node.js",
"dist/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js",
"dist/nodes/Facebook/FacebookGraphApi.node.js",
"dist/nodes/Facebook/FacebookTrigger.node.js",
"dist/nodes/Figma/FigmaTrigger.node.js",
@ -539,6 +540,7 @@
"dist/nodes/Mailjet/Mailjet.node.js",
"dist/nodes/Mailjet/MailjetTrigger.node.js",
"dist/nodes/Mandrill/Mandrill.node.js",
"dist/nodes/ManualTrigger/ManualTrigger.node.js",
"dist/nodes/Markdown/Markdown.node.js",
"dist/nodes/Marketstack/Marketstack.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 startNodeType = 'n8n-nodes-base.start';
for (const nodeName of nodeNames) {
const startingNodeTypes = [
'n8n-nodes-base.manualTrigger',
'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];
if (node.type === startNodeType) {
if (startingNodeTypes.includes(node.type)) {
if (node.disabled === true) {
continue;
}
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.
@ -17,3 +17,22 @@ export class WorkflowOperationError extends Error {
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 {}