mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 04:47:29 -08:00
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:
parent
128c3b83df
commit
dae01f3abe
12
package-lock.json
generated
12
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
|
|
|
@ -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
30
packages/cli/src/utils.ts
Normal 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');
|
9
packages/editor-ui/public/static/webhook-icon.svg
Normal file
9
packages/editor-ui/public/static/webhook-icon.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 356 KiB |
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -66,6 +66,7 @@
|
|||
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId"/>
|
||||
</span>
|
||||
<SaveButton
|
||||
type="secondary"
|
||||
:saved="!this.isDirty && !this.isNewWorkflow"
|
||||
:disabled="isWorkflowSaving"
|
||||
@click="onSaveButtonClick"
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
<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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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) {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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[]) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -38,6 +38,8 @@ export const workflowRun = mixins(
|
|||
);
|
||||
}
|
||||
|
||||
this.$store.commit('setSubworkflowExecutionError', null);
|
||||
|
||||
this.$store.commit('addActiveAction', 'workflowRunning');
|
||||
|
||||
let response: IExecutionPushResponse;
|
||||
|
|
|
@ -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>
|
|
@ -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',
|
||||
|
|
39
packages/editor-ui/src/modules/nodeCreator.ts
Normal file
39
packages/editor-ui/src/modules/nodeCreator.ts
Normal 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;
|
|
@ -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[] = []) {
|
||||
|
|
150
packages/editor-ui/src/modules/nodeTypesHelpers.ts
Normal file
150
packages/editor-ui/src/modules/nodeTypesHelpers.ts
Normal 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];
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
95
packages/editor-ui/src/views/CanvasAddButton.vue
Normal file
95
packages/editor-ui/src/views/CanvasAddButton.vue
Normal 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>
|
|
@ -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;
|
||||
|
|
|
@ -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)],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -23,6 +23,9 @@
|
|||
]
|
||||
},
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Helpers"]
|
||||
"Core Nodes": [
|
||||
"Helpers",
|
||||
"Other Trigger Nodes"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -19,6 +19,9 @@
|
|||
]
|
||||
},
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Helpers"]
|
||||
"Core Nodes": [
|
||||
"Helpers",
|
||||
"Other Trigger Nodes"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"]}}',
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -24,6 +24,9 @@
|
|||
},
|
||||
"alias": ["Time", "Scheduler", "Polling"],
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Flow"]
|
||||
"Core Nodes": [
|
||||
"Flow",
|
||||
"Other Trigger Nodes"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,9 @@
|
|||
},
|
||||
"alias": ["Watch", "Monitor"],
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Files"]
|
||||
"Core Nodes":[
|
||||
"Files",
|
||||
"Other Trigger Nodes"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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": []
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -11,6 +11,9 @@
|
|||
]
|
||||
},
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Flow"]
|
||||
"Core Nodes": [
|
||||
"Flow",
|
||||
"Other Trigger Nodes"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,9 @@
|
|||
]
|
||||
},
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Flow"]
|
||||
"Core Nodes": [
|
||||
"Flow",
|
||||
"Other Trigger Nodes"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,9 @@
|
|||
]
|
||||
},
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Flow"]
|
||||
"Core Nodes": [
|
||||
"Flow",
|
||||
"Other Trigger Nodes"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
Loading…
Reference in a new issue