feat(editor): Node creator actions (#4696)

* WIP: Node Actions List UI

* WIP: Recommended Actions and preseting of fields

* WIP: Resource category

* 🎨 Moved actions categorisation to the server

* 🏷️ Add missing INodeAction type

*  Improve SSR categorisation, fix adding of mixed actions

* ♻️ Refactor CategorizedItems to composition api, style fixes

* WIP: Adding multiple nodes

* ♻️ Refactor rest of the NodeCreator component to composition API, conver globalLinkActions to composable

*  Allow actions dragging, fix search and refactor passing of actions to categorized items

* 💄 Fix node actions title

* Migrate to the pinia store, add posthog feature and various fixes

* 🐛 Fix filtering of trigger actions when not merged

* fix: N8N-5439 — Do not use simple node item when at NodeHelperPanel root

* 🐛 Design review fixes

* 🐛 Fix disabling of merged actions

* Fix trigger root filtering

*  Allow for custom node actions parser, introduce hubspot parser

* 🐛 Fix initial node params validation, fix position of second added node

* 🐛 Introduce operations category, removed canvas node names overrride, fix API actions display and prevent dragging of action nodes

*  Prevent NDV auto-open feature flag

* 🐛 Inject recommened action for trigger nodes without actions

* Refactored NodeCreatorNode to Storybook, change filtering of merged nodes for the trigger helper panel, minor fixes

* Improve rendering of app nodes and animation

* Cleanup, any only enable accordion transition on triggerhelperpanel

* Hide node creator scrollbars in Firefox

* Minor styles fixes

* Do not copy the array in rendering method

* Removed unused props

* Fix memory leak

* Fix categorisation of regular nodes with a single resource

* Implement telemetry calls for node actions

* Move categorization to FE

* Fix client side actions categorisation

* Skip custom action show

* Only load tooltip for NodeIcon if necessary

* Fix lodash startCase import

* Remove lodash.startcase

* Cleanup

* Fix node creator autofocus on "tab"

* Prevent posthog getFeatureFlag from crashing

* Debugging preview env search issues

* Remove logs

* Make sure the pre-filled params are update not overwritten

* Get rid of transition in itemiterator

* WIP: Rough version of NodeActions keyboard navigation, replace nodeCreator composable with Pinia store module

* Rewrite to add support for ActionItem to ItemIterator and make CategorizedItems accept items props

* Fix category item counter & cleanup

* Add APIHint to actions search no-result, clean up NodeCreatorNode

* Improve node actions no results message

* Remove logging, fix filtering of recommended placeholder category

* Remove unused NodeActions component and node merging feature falg

* Do not show regular nodes without actions

* Make sure to add manual trigger when adding http node via actions hint

* Fixed api hint footer line height

* Prevent pointer-events od NodeIcon img and remove "this" from template

* Address PR points

* Fix e2e specs

* Make sure canvas ia loaded

* Make sure canvas ia loaded before opening nodeCreator in e2e spec

* Fix flaky workflows tags e2e getter

* Imrpove node creator click outside UX, add manual node to regular nodes added from trigger panel

* Add manual trigger node if dragging regular from trigger panel
This commit is contained in:
OlegIvaniv 2022-12-09 10:56:36 +01:00 committed by GitHub
parent b7c1359090
commit 79fe57dad8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 2498 additions and 1515 deletions

View file

@ -70,11 +70,20 @@ describe('Node Creator', () => {
.should('exist')
.should('contain.text', 'We didn\'t make that... yet');
nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image');
nodeCreatorFeature.getters.creatorItem().should('have.length', 1);
nodeCreatorFeature.getters.searchBar().find('input').clear().type('this node totally does not exist');
nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
nodeCreatorFeature.getters.searchBar().find('input').clear()
nodeCreatorFeature.getters.getCreatorItem('On App Event').click();
nodeCreatorFeature.getters.searchBar().find('input').clear().type('edit image');
nodeCreatorFeature.getters.creatorItem().should('have.length', 0);
nodeCreatorFeature.getters.noResults()
.should('exist')
.should('contain.text', 'To see results, click here');
.should('contain.text', 'To see all results, click here');
nodeCreatorFeature.getters.noResults().contains('click here').click();
nodeCreatorFeature.getters.nodeCreatorTabs().should('exist');
@ -85,6 +94,7 @@ describe('Node Creator', () => {
})
it('should add manual trigger node', () => {
cy.get('.el-loading-mask').should('not.exist');
nodeCreatorFeature.getters.canvasAddButton().click();
nodeCreatorFeature.getters.getCreatorItem('Manually').click();
@ -95,7 +105,7 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
// TODO: Replace once we have canvas feature utils
cy.get('div').contains("On clicking 'execute'").should('exist');
cy.get('div').contains("Add first step").should('exist');
})
it('check if non-core nodes are rendered', () => {
cy.wait('@nodesIntercept').then((interception) => {
@ -144,7 +154,7 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.getCreatorItem(customCategory).should('exist');
nodeCreatorFeature.actions.toggleCategory(customCategory);
nodeCreatorFeature.getters.getCreatorItem(customNode).findChildByTestId('node-item-community-tooltip').should('exist');
nodeCreatorFeature.getters.getCreatorItem(customNode).findChildByTestId('node-creator-item-tooltip').should('exist');
nodeCreatorFeature.getters.getCreatorItem(customNode).contains(customNodeDescription).should('exist');
nodeCreatorFeature.actions.selectNode(customNode);

View file

@ -66,7 +66,7 @@ describe('Workflow Actions', () => {
it('should add more tags', () => {
WorkflowPage.getters.newTagLink().click();
WorkflowPage.actions.addTags(TEST_WF_TAGS);
WorkflowPage.getters.workflowTagElements().first().click();
WorkflowPage.getters.firstWorkflowTagElement().click();
WorkflowPage.actions.addTags(['Another one']);
WorkflowPage.getters.workflowTagElements().should('have.length', TEST_WF_TAGS.length + 1);
});
@ -74,7 +74,7 @@ describe('Workflow Actions', () => {
it('should remove tags by clicking X in tag', () => {
WorkflowPage.getters.newTagLink().click();
WorkflowPage.actions.addTags(TEST_WF_TAGS);
WorkflowPage.getters.workflowTagElements().first().click();
WorkflowPage.getters.firstWorkflowTagElement().click();
WorkflowPage.getters.workflowTagsContainer().find('.el-tag__close').first().click();
cy.get('body').type('{enter}');
WorkflowPage.getters.workflowTagElements().should('have.length', TEST_WF_TAGS.length - 1);
@ -83,7 +83,7 @@ describe('Workflow Actions', () => {
it('should remove tags from dropdown', () => {
WorkflowPage.getters.newTagLink().click();
WorkflowPage.actions.addTags(TEST_WF_TAGS);
WorkflowPage.getters.workflowTagElements().first().click();
WorkflowPage.getters.firstWorkflowTagElement().click();
WorkflowPage.getters.workflowTagsDropdown().find('li').first().click();
cy.get('body').type('{enter}');
WorkflowPage.getters.workflowTagElements().should('have.length', TEST_WF_TAGS.length - 1);

View file

@ -16,12 +16,13 @@ export class NodeCreator extends BasePage {
creatorItem: () => cy.getByTestId('item-iterator-item'),
communityNodeTooltip: () => cy.getByTestId('node-item-community-tooltip'),
noResults: () => cy.getByTestId('categorized-no-results'),
nodeItemName: () => cy.getByTestId('node-item-name'),
nodeItemName: () => cy.getByTestId('node-creator-item-name'),
activeSubcategory: () => cy.getByTestId('categorized-items-subcategory'),
expandedCategories: () => this.getters.creatorItem().find('>div').filter('.active').invoke('text'),
};
actions = {
openNodeCreator: () => {
cy.get('.el-loading-mask').should('not.exist');
this.getters.plusButton().click();
this.getters.nodeCreator().should('be.visible')
},

View file

@ -10,6 +10,7 @@ export class WorkflowPage extends BasePage {
workflowTagsContainer: () => cy.getByTestId('workflow-tags-container'),
workflowTagsInput: () => this.getters.workflowTagsContainer().then(($el) => cy.wrap($el.find('input').first())),
workflowTagElements: () => cy.get('[data-test-id="workflow-tags-container"] span.tags > span'),
firstWorkflowTagElement: () => cy.get('[data-test-id="workflow-tags-container"] span.tags > span:nth-child(1)'),
workflowTagsDropdown: () => cy.getByTestId('workflow-tags-dropdown'),
newTagLink: () => cy.getByTestId('new-tag-link'),
saveButton: () => cy.getByTestId('workflow-save-button'),
@ -43,12 +44,14 @@ export class WorkflowPage extends BasePage {
addInitialNodeToCanvas: (nodeDisplayName: string) => {
this.getters.canvasPlusButton().click();
this.getters.nodeCreatorSearchBar().type(nodeDisplayName);
this.getters.nodeCreatorSearchBar().type('{enter}{esc}');
this.getters.nodeCreatorSearchBar().type('{enter}');
cy.get('body').type('{esc}');
},
addNodeToCanvas: (nodeDisplayName: string) => {
this.getters.nodeCreatorPlusButton().click();
this.getters.nodeCreatorSearchBar().type(nodeDisplayName);
this.getters.nodeCreatorSearchBar().type('{enter}{esc}');
this.getters.nodeCreatorSearchBar().type('{enter}');
cy.get('body').type('{esc}');
},
openNodeNdv: (nodeTypeName: string) => {
this.getters.canvasNodeByName(nodeTypeName).dblclick();

View file

@ -0,0 +1,58 @@
/* tslint:disable:variable-name */
import N8nNodeCreatorNode from './NodeCreatorNode.vue';
import { StoryFn } from '@storybook/vue';
export default {
title: 'Modules/Node Creator Node',
component: N8nNodeCreatorNode,
};
const DefaultTemplate: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nNodeCreatorNode,
},
template: `
<n8n-node-creator-node v-bind="$props">
<template v-slot:icon>
<img src="https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/cartman.svg" />
</template>
</n8n-node-creator-node>
`,
});
export const WithTitle = DefaultTemplate.bind({});
WithTitle.args = {
title: 'Node with title',
tooltipHtml: '<b>Bold</b> tooltip',
description:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean et vehicula ipsum, eu facilisis lacus. Aliquam commodo vel elit eget mollis. Quisque ac elit non purus iaculis placerat. Quisque fringilla ultrices nisi sed porta.',
};
const PanelTemplate: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nNodeCreatorNode,
},
data() {
return {
isPanelActive: false,
};
},
template: `
<n8n-node-creator-node v-bind="$props" :isPanelActive="isPanelActive" @click.capture="isPanelActive = true">
<template v-slot:icon>
<img src="https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/cartman.svg" />
</template>
<template v-slot:panel>
<p style="width: 100%; height: 300px; background: white">Lorem ipsum dolor sit amet</p>
<button @click="isPanelActive = false">Close</button>
</template>
</n8n-node-creator-node>
`,
});
export const WithPanel = PanelTemplate.bind({});
WithPanel.args = {
title: 'Node with panel',
isTrigger: true,
};

View file

@ -0,0 +1,125 @@
<template>
<div
:class="{
[$style.creatorNode]: true,
[$style.hasAction]: !showActionArrow,
}"
v-on="$listeners"
v-bind="$attrs"
>
<div :class="$style.nodeIcon">
<slot name="icon" />
</div>
<div>
<div :class="$style.details">
<span :class="$style.name" v-text="title" data-test-id="node-creator-item-name" />
<trigger-icon v-if="isTrigger" :class="$style.triggerIcon" />
<n8n-tooltip
v-if="!!$slots.tooltip"
placement="top"
data-test-id="node-creator-item-tooltip"
>
<template #content>
<slot name="tooltip" />
</template>
<n8n-icon :class="$style.tooltipIcon" icon="cube" />
</n8n-tooltip>
</div>
<p :class="$style.description" v-if="description" v-text="description" />
</div>
<slot name="dragContent" />
<button :class="$style.panelIcon" v-if="showActionArrow">
<font-awesome-icon :class="$style.panelArrow" icon="arrow-right" />
</button>
</div>
</template>
<script setup lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import TriggerIcon from './TriggerIcon.vue';
import N8nTooltip from '../N8nTooltip';
export interface Props {
active?: boolean;
isTrigger?: boolean;
description?: string;
title: string;
showActionArrow?: boolean;
}
defineProps<Props>();
defineEmits<{
(event: 'tooltipClick', $e: MouseEvent): void;
}>();
</script>
<style lang="scss" module>
.creatorNode {
display: flex;
align-items: center;
cursor: pointer;
z-index: 1;
padding: 11px 8px 11px 0;
&.hasAction {
user-select: none;
}
}
.creatorNode:hover .panelIcon {
color: var(--color-text-light);
}
.panelIcon {
flex-grow: 1;
display: flex;
justify-content: flex-end;
align-items: center;
margin-left: var(--spacing-2xs);
color: var(--color-text-lighter);
cursor: pointer;
background: transparent;
border: none;
}
.tooltipIcon {
margin-left: var(--spacing-3xs);
}
.panelArrow {
font-size: var(--font-size-2xs);
width: 12px;
}
.details {
align-items: center;
}
.nodeIcon {
display: flex;
margin-right: var(--spacing-s);
& > :global(*) {
min-width: 25px;
max-width: 25px;
}
}
.name {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
line-height: 1.115rem;
}
.description {
margin-top: var(--spacing-5xs);
font-size: var(--font-size-2xs);
line-height: 1rem;
font-weight: 400;
color: var(--node-creator-description-colo, var(--color-text-base));
}
.triggerIcon {
margin-left: var(--spacing-2xs);
}
</style>
<style lang="scss" scoped>
.el-tooltip svg {
color: var(--color-foreground-xdark);
}
</style>

View file

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

View file

@ -0,0 +1,3 @@
import NodeCreatorNode from './NodeCreatorNode.vue';
export default NodeCreatorNode;

View file

@ -2,24 +2,35 @@
<div class="n8n-node-icon">
<div
:class="{
[$style['node-icon-wrapper']]: true,
[$style['circle']]: this.circle,
[$style['disabled']]: this.disabled,
[$style.nodeIconWrapper]: true,
[$style.circle]: circle,
[$style.disabled]: disabled,
}"
:style="iconStyleData"
v-on="$listeners"
>
<n8n-tooltip placement="top" :disabled="!showTooltip">
<!-- ElementUI tooltip is prone to memory-leaking so we only render it if we really need it -->
<n8n-tooltip placement="top" :disabled="!showTooltip" v-if="showTooltip">
<template #content>{{ nodeTypeName }}</template>
<div v-if="type !== 'unknown'" :class="$style['icon']">
<img v-if="type === 'file'" :src="src" :class="$style['node-icon-image']" />
<img v-if="type === 'file'" :src="src" :class="$style.nodeIconImage" />
<font-awesome-icon v-else :icon="name" :style="fontStyleData" />
</div>
<div v-else :class="$style['node-icon-placeholder']">
<div v-else :class="$style.nodeIconPlaceholder">
{{ nodeTypeName ? nodeTypeName.charAt(0) : '?' }}
?
</div>
</n8n-tooltip>
<template v-else>
<div v-if="type !== 'unknown'" :class="$style.icon">
<img v-if="type === 'file'" :src="src" :class="$style.nodeIconImage" />
<font-awesome-icon v-else :icon="name" :style="fontStyleData" />
</div>
<div v-else :class="$style.nodeIconPlaceholder">
{{ nodeTypeName ? nodeTypeName.charAt(0) : '?' }}
?
</div>
</template>
</div>
</div>
</template>
@ -91,7 +102,7 @@ export default Vue.extend({
</script>
<style lang="scss" module>
.node-icon-wrapper {
.nodeIconWrapper {
width: 26px;
height: 26px;
border-radius: var(--border-radius-small);
@ -110,13 +121,12 @@ export default Vue.extend({
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
}
.node-icon-placeholder {
.nodeIconPlaceholder {
text-align: center;
}
.node-icon-image {
.nodeIconImage {
width: 100%;
max-width: 100%;
max-height: 100%;

View file

@ -25,6 +25,7 @@ import N8nLoading from '../components/N8nLoading';
import N8nMarkdown from '../components/N8nMarkdown';
import N8nMenu from '../components/N8nMenu';
import N8nMenuItem from '../components/N8nMenuItem';
import N8nNodeCreatorNode from '../components/N8nNodeCreatorNode';
import N8nNodeIcon from '../components/N8nNodeIcon';
import N8nNotice from '../components/N8nNotice';
import N8nOption from '../components/N8nOption';
@ -73,6 +74,7 @@ export default {
app.component('n8n-markdown', N8nMarkdown);
app.component('n8n-menu', N8nMenu);
app.component('n8n-menu-item', N8nMenuItem);
app.component('n8n-node-creator-node', N8nNodeCreatorNode);
app.component('n8n-node-icon', N8nNodeIcon);
app.component('n8n-notice', N8nNotice);
app.component('n8n-option', N8nOption);

View file

@ -86,6 +86,7 @@
"@types/express": "^4.17.6",
"@types/file-saver": "^2.0.1",
"@types/jsonpath": "^0.2.0",
"@types/lodash-es": "^4.17.6",
"@types/lodash.camelcase": "^4.3.6",
"@types/lodash.get": "^4.4.6",
"@types/lodash.set": "^4.3.6",

View file

@ -36,8 +36,8 @@ import mixins from 'vue-typed-mixins';
import { showMessage } from '@/mixins/showMessage';
import { userHelpers } from '@/mixins/userHelpers';
import { loadLanguage } from './plugins/i18n';
import useGlobalLinkActions from '@/composables/useGlobalLinkActions';
import { restApi } from '@/mixins/restApi';
import { globalLinkActions } from '@/mixins/globalLinkActions';
import { mapStores } from 'pinia';
import { useUIStore } from './stores/ui';
import { useSettingsStore } from './stores/settings';
@ -50,7 +50,6 @@ export default mixins(
showMessage,
userHelpers,
restApi,
globalLinkActions,
).extend({
name: 'App',
components: {
@ -58,6 +57,13 @@ export default mixins(
Telemetry,
Modals,
},
setup() {
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
return {
registerCustomAction,
unregisterCustomAction,
};
},
computed: {
...mapStores(
useNodeTypesStore,

View file

@ -35,6 +35,7 @@ import {
INodeCredentials,
INodeListSearchItems,
NodeParameterValueType,
INodeActionTypeDescription,
} from 'n8n-workflow';
import { FAKE_DOOR_FEATURES } from './constants';
@ -808,6 +809,7 @@ export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR';
export interface ISubcategoryItemProps {
subcategory: string;
description: string;
key?: string;
icon?: string;
defaults?: INodeParameters;
iconData?: {
@ -822,19 +824,44 @@ export interface INodeItemProps {
nodeType: INodeTypeDescription;
}
export interface IActionItemProps {
subcategory: string;
nodeType: INodeActionTypeDescription;
}
export interface ICategoryItemProps {
expanded: boolean;
}
export interface INodeCreateElement {
type: 'node' | 'category' | 'subcategory';
export interface CreateElementBase {
category: string;
key: string;
includedByTrigger?: boolean;
includedByRegular?: boolean;
properties: ISubcategoryItemProps | INodeItemProps | ICategoryItemProps;
}
export interface NodeCreateElement extends CreateElementBase {
type: 'node';
properties: INodeItemProps;
}
export interface CategoryCreateElement extends CreateElementBase {
type: 'category';
properties: ICategoryItemProps;
}
export interface SubcategoryCreateElement extends CreateElementBase {
type: 'subcategory';
properties: ISubcategoryItemProps;
}
export interface ActionCreateElement extends CreateElementBase {
type: 'action';
properties: IActionItemProps;
}
export type INodeCreateElement = NodeCreateElement | CategoryCreateElement | SubcategoryCreateElement | ActionCreateElement;
export interface ICategoriesWithNodes {
[category: string]: {
[subcategory: string]: {

View file

@ -84,16 +84,16 @@ export default Vue.extend({
position[0] -= DEFAULT_STICKY_WIDTH / 2;
position[1] -= DEFAULT_STICKY_HEIGHT / 2;
this.$emit('addNode', {
this.$emit('addNode', [{
nodeTypeName: STICKY_NODE_TYPE,
position,
});
}]);
},
closeNodeCreator() {
this.$emit('toggleNodeCreator', { createNodeActive: false });
},
nodeTypeSelected(nodeTypeName: string) {
this.$emit('addNode', { nodeTypeName });
nodeTypeSelected(nodeTypeNames: string[]) {
this.$emit('addNode', nodeTypeNames.map(nodeTypeName => ({ nodeTypeName })));
this.closeNodeCreator();
},
},

View file

@ -0,0 +1,162 @@
<template>
<n8n-node-creator-node
:key="`${action.actionKey}_${action.displayName}`"
@click="onActionClick(action)"
@dragstart="onDragStart"
@dragend="onDragEnd"
draggable
:class="$style.action"
:title="action.displayName"
:isTrigger="isTriggerAction(action)"
>
<template #dragContent>
<div :class="$style.draggableDataTransfer" ref="draggableDataTransfer"/>
<div
:class="$style.draggable"
:style="draggableStyle"
v-show="dragging"
>
<node-icon :nodeType="nodeType" @click.capture.stop :size="40" :shrink="false" />
</div>
</template>
<template #icon>
<node-icon :nodeType="action" />
</template>
</n8n-node-creator-node>
</template>
<script setup lang="ts">
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
import { INodeTypeDescription, INodeActionTypeDescription } from 'n8n-workflow';
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
import { IUpdateInformation } from '@/Interface';
import NodeIcon from '@/components/NodeIcon.vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
export interface Props {
nodeType: INodeTypeDescription,
action: INodeActionTypeDescription,
}
const props = defineProps<Props>();
const instance = getCurrentInstance();
const telemetry = instance?.proxy.$telemetry;
const { getActionData, getNodeTypesWithManualTrigger, setAddedNodeActionParameters } = useNodeCreatorStore();
const state = reactive({
dragging: false,
draggablePosition: {
x: -100,
y: -100,
},
storeWatcher: null as Function | null,
draggableDataTransfer: null as Element | null,
});
const emit = defineEmits<{
(event: 'actionSelected', action: IUpdateInformation): void,
(event: 'dragstart', $e: DragEvent): void,
(event: 'dragend', $e: DragEvent): void,
}>();
const draggableStyle = computed<{ top: string; left: string; }>(() => ({
top: `${state.draggablePosition.y}px`,
left: `${state.draggablePosition.x}px`,
}));
const actionData = computed(() => getActionData(props.action));
const isTriggerAction = (action: INodeActionTypeDescription) => action.name?.toLowerCase().includes('trigger');
function onActionClick(actionItem: INodeActionTypeDescription) {
emit('actionSelected', getActionData(actionItem));
}
function onDragStart(event: DragEvent): void {
/**
* Workaround for firefox, that doesn't attach the pageX and pageY coordinates to "ondrag" event.
* All browsers attach the correct page coordinates to the "dragover" event.
* @bug https://bugzilla.mozilla.org/show_bug.cgi?id=505521
*/
document.body.addEventListener("dragover", onDragOver);
const { pageX: x, pageY: y } = event;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.dropEffect = "copy";
event.dataTransfer.setDragImage(state.draggableDataTransfer as Element, 0, 0);
event.dataTransfer.setData('nodeTypeName', getNodeTypesWithManualTrigger(actionData.value?.key).join(','));
state.storeWatcher = setAddedNodeActionParameters(actionData.value, telemetry);
document.body.addEventListener("dragend", onDragEnd);
}
state.dragging = true;
state.draggablePosition = { x, y };
emit('dragstart', event);
}
function onDragOver(event: DragEvent): void {
if (!state.dragging || event.pageX === 0 && event.pageY === 0) {
return;
}
const [x,y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
state.draggablePosition = { x, y };
}
function onDragEnd(event: DragEvent): void {
if(state.storeWatcher) state.storeWatcher();
document.body.removeEventListener("dragend", onDragEnd);
document.body.removeEventListener("dragover", onDragOver);
emit('dragend', event);
state.dragging = false;
setTimeout(() => {
state.draggablePosition = { x: -100, y: -100 };
}, 300);
}
const { draggableDataTransfer, dragging } = toRefs(state);
</script>
<style lang="scss" module>
.action {
margin-left: 15px;
margin-right: 12px;
--trigger-icon-background-color: #{$trigger-icon-background-color};
--trigger-icon-border-color: #{$trigger-icon-border-color};
}
.nodeIcon {
margin-right: var(--spacing-s);
}
.apiHint {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
padding-top: var(--spacing-s);
line-height: var(--font-line-height-regular);
border-top: 1px solid #DBDFE7;
z-index: 1;
// Prevent double borders when the last category is collapsed
margin-top: -1px;
}
.draggable {
width: 100px;
height: 100px;
position: fixed;
z-index: 1;
opacity: 0.66;
border: 2px solid var(--color-foreground-xdark);
border-radius: var(--border-radius-large);
background-color: var(--color-background-xlight);
display: flex;
justify-content: center;
align-items: center;
}
.draggableDataTransfer {
width: 1px;
height: 1px;
}
</style>

View file

@ -1,88 +1,50 @@
<template>
<div :class="$style.category">
<span :class="$style.name">
{{ renderCategoryName(categoryName) }} ({{ nodesCount }})
{{ renderCategoryName(item.category) }}{{ count !== undefined ? ` (${count})` : ''}}
</span>
<font-awesome-icon
:class="$style.arrow"
v-if="isExpanded"
icon="chevron-down"
v-if="item.properties.expanded"
:class="$style.arrow"
/>
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
<script lang="ts" setup>
import { computed, getCurrentInstance } from 'vue';
import camelcase from 'lodash.camelcase';
import { CategoryName } from '@/plugins/i18n';
import { INodeCreateElement, ICategoriesWithNodes } from '@/Interface';
import { NODE_TYPE_COUNT_MAPPER } from '@/constants';
import { mapStores } from 'pinia';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { INodeCreateElement, ICategoryItemProps } from '@/Interface';
export interface Props {
item: INodeCreateElement;
count?: number;
}
const props = defineProps<Props>();
const instance = getCurrentInstance();
export default Vue.extend({
props: {
item: {
type: Object as PropType<INodeCreateElement>,
},
},
computed: {
...mapStores(
useNodeCreatorStore,
useNodeTypesStore,
),
selectedType(): "Regular" | "Trigger" | "All" {
return this.nodeCreatorStore.selectedType;
},
categoriesWithNodes(): ICategoriesWithNodes {
return this.nodeTypesStore.categoriesWithNodes;
},
categorizedItems(): INodeCreateElement[] {
return this.nodeTypesStore.categorizedItems;
},
categoryName() {
return camelcase(this.item.category);
},
nodesCount(): number {
const currentCategory= (this.categoriesWithNodes as ICategoriesWithNodes)[this.item.category];
const subcategories = Object.keys(currentCategory);
const isExpanded = computed<boolean>(() =>(props.item.properties as ICategoryItemProps).expanded);
// 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];
function renderCategoryName(categoryName: string) {
const camelCasedCategoryName = camelcase(categoryName) as CategoryName;
const key = `nodeCreator.categoryNames.${camelCasedCategoryName}` as const;
for (const countKey of countKeys) {
accu += currentCategory[subcategory][(countKey as "triggerCount" | "regularCount")];
}
return accu;
}, 0);
return count;
},
},
methods: {
renderCategoryName(categoryName: CategoryName) {
const key = `nodeCreator.categoryNames.${categoryName}` as const;
return this.$locale.exists(key) ? this.$locale.baseText(key) : this.item.category;
},
},
});
return instance?.proxy.$locale.exists(key)
? instance?.proxy.$locale.baseText(key)
: categoryName;
}
</script>
<style lang="scss" module>
.category {
font-size: 11px;
font-weight: 700;
font-weight: var(--font-weight-bold);
letter-spacing: 1px;
line-height: 11px;
padding: 10px 0;
margin: 0 12px;
margin: 0 var(--spacing-xs);
border-bottom: 1px solid $node-creator-border-color;
display: flex;
text-transform: uppercase;
@ -94,7 +56,7 @@ export default Vue.extend({
}
.arrow {
font-size: 12px;
font-size: var(--font-size-2xs);
width: 12px;
color: $node-creator-arrow-color;
}

View file

@ -1,74 +0,0 @@
<template>
<div
:class="{
container: true,
clickable: clickable,
active: active,
}"
v-on="$listeners"
>
<category-item
v-if="item.type === 'category'"
:item="item"
/>
<subcategory-item
v-else-if="item.type === 'subcategory'"
:item="item"
/>
<node-item
v-else-if="item.type === 'node'"
:nodeType="item.properties.nodeType"
@dragstart="$listeners.dragstart"
@dragend="$listeners.dragend"
/>
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
import { INodeCreateElement } from '@/Interface';
import NodeItem from './NodeItem.vue';
import SubcategoryItem from './SubcategoryItem.vue';
import CategoryItem from './CategoryItem.vue';
export default Vue.extend({
name: 'CreatorItem',
components: {
CategoryItem,
SubcategoryItem,
NodeItem,
},
props: {
item: {
type: Object as PropType<INodeCreateElement>,
},
active: {
type: Boolean,
},
clickable: {
type: Boolean,
},
lastNode: {
type: Boolean,
},
},
});
</script>
<style lang="scss" scoped>
.container {
position: relative;
border-left: 2px solid transparent;
&:hover {
border-color: $node-creator-item-hover-border-color;
}
&.active {
border-color: $color-primary !important;
}
}
</style>

View file

@ -1,7 +1,6 @@
<template>
<div
:is="transitionsEnabled ? 'transition-group' : 'div'"
class="item-iterator"
:class="$style.itemIterator"
name="accordion"
@before-enter="beforeEnter"
@enter="enter"
@ -9,108 +8,217 @@
@leave="leave"
>
<div
v-for="(item, index) in elements"
:key="item.key"
:class="item.type"
:data-key="item.key"
v-for="(item, index) in renderedItems"
:key="`${item.key}-${index}`"
data-test-id="item-iterator-item"
:class="{
'clickable': !disabled,
[$style[item.type]]: true,
[$style.active]: activeIndex === index && !disabled,
[$style.iteratorItem]: true
}"
ref="iteratorItems"
@click="wrappedEmit('selected', item)"
>
<creator-item
<category-item
v-if="item.type === 'category'"
:item="item"
:active="activeIndex === index && !disabled"
:clickable="!disabled"
:lastNode="
index === elements.length - 1 || elements[index + 1].type !== 'node'
"
@click="$emit('selected', item)"
@dragstart="emit('dragstart', item, $event)"
@dragend="emit('dragend', item, $event)"
:count="enableGlobalCategoriesCounter ? getCategoryCount(item) : undefined"
/>
<subcategory-item
v-else-if="item.type === 'subcategory'"
:item="item"
/>
<node-item
v-else-if="item.type === 'node'"
:nodeType="item.properties.nodeType"
:allow-actions="withActionsGetter && withActionsGetter(item)"
@dragstart="wrappedEmit('dragstart', item, $event)"
@dragend="wrappedEmit('dragend', item, $event)"
@nodeTypeSelected="$listeners.nodeTypeSelected"
@actionsOpen="$listeners.actionsOpen"
/>
<action-item
v-else-if="item.type === 'action'"
:nodeType="item.properties.nodeType"
:action="item.properties.nodeType"
@dragstart="wrappedEmit('dragstart', item, $event)"
@dragend="wrappedEmit('dragend', item, $event)"
/>
</div>
<aside
v-for="item in elements.length"
v-show="(renderedItems.length < item)"
:key="item"
:class="$style.loadingItem"
>
<n8n-loading :loading="true" :rows="1" variant="p" />
</aside>
</div>
</template>
<script lang="ts">
import { INodeCreateElement } from '@/Interface';
<script setup lang="ts">
import { INodeCreateElement, CategoryCreateElement, NodeCreateElement } from '@/Interface';
import NodeItem from './NodeItem.vue';
import SubcategoryItem from './SubcategoryItem.vue';
import CategoryItem from './CategoryItem.vue';
import ActionItem from './ActionItem.vue';
import { reactive, toRefs, onMounted, watch, onUnmounted, ref } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { NODE_TYPE_COUNT_MAPPER } from '@/constants';
import Vue, { PropType } from 'vue';
import CreatorItem from './CreatorItem.vue';
export interface Props {
elements: INodeCreateElement[];
activeIndex?: number;
disabled?: boolean;
lazyRender?: boolean;
withActionsGetter?: (element: NodeCreateElement) => boolean;
enableGlobalCategoriesCounter?: boolean;
}
export default Vue.extend({
name: 'ItemIterator',
components: {
CreatorItem,
},
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) {
return;
}
this.$emit(eventName, { element, event });
},
beforeEnter(el: HTMLElement) {
el.style.height = '0';
},
enter(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
},
beforeLeave(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
},
leave(el: HTMLElement) {
el.style.height = '0';
},
},
const props = withDefaults(defineProps<Props>(), {
elements: () => [],
});
const emit = defineEmits<{
(event: 'selected', element: INodeCreateElement, $e?: Event): void,
(event: 'dragstart', element: INodeCreateElement, $e: Event): void,
(event: 'dragend', element: INodeCreateElement, $e: Event): void,
}>();
const state = reactive({
renderedItems: [] as INodeCreateElement[],
renderAnimationRequest: 0,
});
const iteratorItems = ref<HTMLElement[]>([]);
function wrappedEmit(event: 'selected' | 'dragstart' | 'dragend', element: INodeCreateElement, $e?: Event) {
if (props.disabled) return;
emit((event as 'selected' || 'dragstart' || 'dragend'), element, $e);
}
function getCategoryCount(item: CategoryCreateElement) {
const { categoriesWithNodes } = useNodeTypesStore();
const currentCategory = categoriesWithNodes[item.category];
const subcategories = Object.keys(currentCategory);
// We need to sum subcategories count for the curent nodeType view
// to get the total count of category
const count = subcategories.reduce((accu: number, subcategory: string) => {
const countKeys = NODE_TYPE_COUNT_MAPPER[useNodeCreatorStore().selectedType];
for (const countKey of countKeys) {
accu += currentCategory[subcategory][(countKey as "triggerCount" | "regularCount")];
}
return accu;
}, 0);
return count;
}
// Lazy render large items lists to prevent the browser from freezing
// when loading many items.
function renderItems() {
if(props.elements.length <= 20 || props.lazyRender === false) {
state.renderedItems = props.elements;
return;
};
if (state.renderedItems.length < props.elements.length) {
state.renderedItems.push(...props.elements.slice(state.renderedItems.length, state.renderedItems.length + 10));
state.renderAnimationRequest = window.requestAnimationFrame(renderItems);
}
}
function beforeEnter(el: HTMLElement) {
el.style.height = '0';
}
function enter(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
}
function beforeLeave(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
}
function leave(el: HTMLElement) {
el.style.height = '0';
}
onMounted(() => {
renderItems();
});
onUnmounted(() => {
window.cancelAnimationFrame(state.renderAnimationRequest);
state.renderedItems = [];
});
// Make sure the active item is always visible
// scroll if needed
watch(() => props.activeIndex, async () => {
if(props.activeIndex === undefined) return;
iteratorItems.value[props.activeIndex]?.scrollIntoView({ block: 'nearest' });
});
// Trigger elements re-render when they change
watch(() => props.elements, async () => {
window.cancelAnimationFrame(state.renderAnimationRequest);
state.renderedItems = [];
renderItems();
});
const { renderedItems } = toRefs(state);
</script>
<style lang="scss" scoped>
.item-iterator > *:last-child {
margin-bottom: var(--spacing-2xl);
}
.accordion-enter {
opacity: 0;
<style lang="scss" module>
.loadingItem {
height: 48px;
margin: 0 var(--search-margin, var(--spacing-s));
}
.iteratorItem {
// Make sure border is fully visible
margin-left: 1px;
position: relative;
&::before {
content: "";
position: absolute;
left: -1px;
top: 0;
bottom: 0;
border-left: 2px solid transparent;
}
&:hover::before {
border-color: $node-creator-item-hover-border-color;
}
.accordion-leave-active {
opacity: 1;
}
&.active::before {
border-color: $color-primary !important;
}
.accordion-leave-active {
transition: all 0.25s ease, opacity 0.1s ease;
margin-top: 0;
}
&.category.singleCategory {
display: none;
}
.accordion-enter-active {
transition: all 0.25s ease, opacity 0.25s ease;
margin-top: 0;
}
.accordion-leave-to {
opacity: 0;
.itemIterator {
> *:last-child {
margin-bottom: var(--spacing-2xl);
}
}
.accordion-enter-to {
opacity: 1;
.action {
&:last-of-type {
margin-bottom: var(--spacing-s);
}
}
.node + .category {
margin-top: 15px;
margin-top: var(--spacing-s);
}
</style>

View file

@ -5,9 +5,8 @@
>
<div class="main-panel">
<trigger-helper-panel
v-if="selectedType === TRIGGER_NODE_FILTER"
:searchItems="searchItems"
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
v-if="nodeCreatorStore.selectedType === TRIGGER_NODE_FILTER"
@nodeTypeSelected="$listeners.nodeTypeSelected"
>
<template #header>
<type-selector/>
@ -15,10 +14,15 @@
</trigger-helper-panel>
<categorized-items
v-else
enable-global-categories-counter
:categorizedItems="categorizedItems"
:categoriesWithNodes="categoriesWithNodes"
:searchItems="searchItems"
:excludedSubcategories="[OTHER_TRIGGER_NODES_SUBCATEGORY]"
:initialActiveCategories="[CORE_NODES_CATEGORY]"
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
:allItems="categorizedItems"
@nodeTypeSelected="$listeners.nodeTypeSelected"
@actionsOpen="() => {}"
>
<template #header>
<type-selector />
@ -28,73 +32,58 @@
</div>
</template>
<script lang="ts">
import { PropType } from 'vue';
<script setup lang="ts">
import { watch, getCurrentInstance, onMounted, onUnmounted } from 'vue';
import { externalHooks } from '@/mixins/externalHooks';
import mixins from 'vue-typed-mixins';
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';
import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { useNodeTypesStore } from '@/stores/nodeTypes';
export default mixins(externalHooks).extend({
name: 'NodeCreateList',
components: {
TriggerHelperPanel,
CategorizedItems,
TypeSelector,
},
props: {
searchItems: {
type: Array as PropType<INodeCreateElement[] | null>,
},
},
data() {
return {
CORE_NODES_CATEGORY,
TRIGGER_NODE_FILTER,
ALL_NODE_FILTER,
OTHER_TRIGGER_NODES_SUBCATEGORY,
};
},
computed: {
...mapStores(
useNodeCreatorStore,
useWorkflowsStore,
),
selectedType(): string {
return this.nodeCreatorStore.selectedType;
},
},
watch: {
selectedType(newValue, oldValue) {
this.$externalHooks().run('nodeCreateList.selectedTypeChanged', {
oldValue,
newValue,
});
this.$telemetry.trackNodesPanel('nodeCreateList.selectedTypeChanged', {
old_filter: oldValue,
new_filter: newValue,
workflow_id: this.workflowsStore.workflowId,
});
},
},
mounted() {
this.$externalHooks().run('nodeCreateList.mounted');
// Make sure tabs are visible on mount
this.nodeCreatorStore.showTabs = true;
},
destroyed() {
this.nodeCreatorStore.selectedType = ALL_NODE_FILTER;
this.$externalHooks().run('nodeCreateList.destroyed');
this.$telemetry.trackNodesPanel('nodeCreateList.destroyed', { workflow_id: this.workflowsStore.workflowId });
},
export interface Props {
searchItems?: INodeCreateElement[];
}
withDefaults(defineProps<Props>(), {
searchItems: () => [],
});
const instance = getCurrentInstance();
const { $externalHooks } = new externalHooks();
const { workflowId } = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
const { categorizedItems, categoriesWithNodes } = useNodeTypesStore();
watch(() => nodeCreatorStore.selectedType, (newValue, oldValue) => {
$externalHooks().run('nodeCreateList.selectedTypeChanged', {
oldValue,
newValue,
});
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.selectedTypeChanged', {
old_filter: oldValue,
new_filter: newValue,
workflow_id: workflowId,
});
});
onMounted(() => {
$externalHooks().run('nodeCreateList.mounted');
// Make sure tabs are visible on mount
nodeCreatorStore.setShowTabs(true);
});
onUnmounted(() => {
nodeCreatorStore.setSelectedType(ALL_NODE_FILTER);
$externalHooks().run('nodeCreateList.destroyed');
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.destroyed', { workflow_id: workflowId });
});
</script>
<style lang="scss" scoped>
.container {
height: 100%;

View file

@ -1,5 +1,5 @@
<template>
<div :class="$style.noResults">
<div :class="{[$style.noResults]: true, [$style.iconless]: !showIcon}">
<div :class="$style.icon" v-if="showIcon">
<no-results-icon />
</div>
@ -29,30 +29,16 @@
</template>
<script lang="ts">
<script setup lang="ts">
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,
},
data() {
return {
REQUEST_NODE_FORM_URL,
};
},
});
export interface Props {
showIcon?: boolean;
showRequest?: boolean;
}
defineProps<Props>();
</script>
<style lang="scss" module>
@ -79,9 +65,9 @@ export default Vue.extend({
}
}
.action, .request {
.action p, .request p {
font-size: var(--font-size-s);
line-height: var(--font-line-height-compact);
line-height: var(--font-line-height-xloose);
}
.request {

View file

@ -1,6 +1,6 @@
<template>
<div>
<aside :class="{'node-creator-scrim': true, expanded: !uiStore.sidebarMenuCollapsed, active: showScrim}" />
<aside :class="{'node-creator-scrim': true, active: nodeCreatorStore.showScrim}" />
<slide-transition>
<div
@ -10,10 +10,12 @@
v-click-outside="onClickOutside"
@dragover="onDragOver"
@drop="onDrop"
@mousedown="onMouseDown"
@mouseup="onMouseUp"
data-test-id="node-creator"
>
<main-panel
@nodeTypeSelected="nodeTypeSelected"
@nodeTypeSelected="$listeners.nodeTypeSelected"
:searchItems="searchItems"
/>
</div>
@ -21,96 +23,103 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import Vue from 'vue';
import { computed, watch, reactive, toRefs } from 'vue';
import { INodeCreateElement } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import SlideTransition from '../../transitions/SlideTransition.vue';
import SlideTransition from '@/components/transitions/SlideTransition.vue';
import MainPanel from './MainPanel.vue';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
export default Vue.extend({
name: 'NodeCreator',
components: {
MainPanel,
SlideTransition,
},
props: {
active: {
type: Boolean,
},
},
computed: {
...mapStores(
useNodeCreatorStore,
useNodeTypesStore,
useUIStore,
),
showScrim(): boolean {
return this.nodeCreatorStore.showScrim;
},
visibleNodeTypes(): INodeTypeDescription[] {
return this.nodeTypesStore.visibleNodeTypes;
},
searchItems(): INodeCreateElement[] {
const sorted = [...this.visibleNodeTypes];
sorted.sort((a, b) => {
const textA = a.displayName.toLowerCase();
const textB = b.displayName.toLowerCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
export interface Props {
active?: boolean;
}
return sorted.map((nodeType) => ({
type: 'node',
category: '',
key: `${nodeType.name}`,
properties: {
nodeType,
subcategory: '',
},
includedByTrigger: nodeType.group.includes('trigger'),
includedByRegular: !nodeType.group.includes('trigger'),
}));
},
},
methods: {
onClickOutside (e: Event) {
if (e.type === 'click') {
this.$emit('closeNodeCreator');
}
},
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
onDragOver(event: DragEvent) {
event.preventDefault();
},
onDrop(event: DragEvent) {
if (!event.dataTransfer) {
return;
}
const props = defineProps<Props>();
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
const nodeCreatorBoundingRect = (this.$refs.nodeCreator as Element).getBoundingClientRect();
const emit = defineEmits<{
(event: 'closeNodeCreator'): void,
}>();
// Abort drag end event propagation if dropped inside nodes panel
if (nodeTypeName && event.pageX >= nodeCreatorBoundingRect.x && event.pageY >= nodeCreatorBoundingRect.y) {
event.stopPropagation();
}
},
},
watch: {
active(isActive) {
if(isActive === false) this.nodeCreatorStore.showScrim = false;
},
},
const nodeCreatorStore = useNodeCreatorStore();
const state = reactive({
nodeCreator: null as HTMLElement | null,
mousedownInsideEvent: null as MouseEvent | null,
});
const visibleNodeTypes = computed(() => useNodeTypesStore().visibleNodeTypes);
const searchItems = computed<INodeCreateElement[]>(() => {
const sorted = [...visibleNodeTypes.value];
sorted.sort((a, b) => {
const textA = a.displayName.toLowerCase();
const textB = b.displayName.toLowerCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
return sorted.map((nodeType) => ({
type: 'node',
category: '',
key: `${nodeType.name}`,
properties: {
nodeType,
subcategory: '',
},
includedByTrigger: nodeType.group.includes('trigger'),
includedByRegular: !nodeType.group.includes('trigger'),
}));
});
function onClickOutside (event: Event) {
// We need to prevent cases where user would click inside the node creator
// and try to drag undraggable element. In that case the click event would
// be fired and the node creator would be closed. So we stop that if we detect
// that the click event originated from inside the node creator. And fire click even on the
// original target.
if(state.mousedownInsideEvent) {
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
state.mousedownInsideEvent.target?.dispatchEvent(clickEvent);
state.mousedownInsideEvent = null;
return;
};
if (event.type === 'click') {
emit('closeNodeCreator');
}
}
function onMouseUp() {
state.mousedownInsideEvent = null;
}
function onMouseDown(event: MouseEvent) {
state.mousedownInsideEvent = event;
}
function onDragOver(event: DragEvent) {
event.preventDefault();
}
function onDrop(event: DragEvent) {
if (!event.dataTransfer) {
return;
}
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
const nodeCreatorBoundingRect = (state.nodeCreator as Element).getBoundingClientRect();
// Abort drag end event propagation if dropped inside nodes panel
if (nodeTypeName && event.pageX >= nodeCreatorBoundingRect.x && event.pageY >= nodeCreatorBoundingRect.y) {
event.stopPropagation();
}
}
watch(() => props.active, (isActive) => {
if(isActive === false) nodeCreatorStore.setShowScrim(false);
});
const { nodeCreator } = toRefs(state);
</script>
<style scoped lang="scss">
@ -140,10 +149,6 @@ export default Vue.extend({
pointer-events: none;
transition: opacity 200ms ease-in-out;
&.expanded {
left: $sidebar-expanded-width
}
&.active {
opacity: 0.7;
}

View file

@ -1,206 +1,180 @@
<template>
<div
draggable
<!-- Node Item is draggable only if it doesn't contain actions -->
<n8n-node-creator-node
:draggable="!showActionArrow"
@dragstart="onDragStart"
@dragend="onDragEnd"
:class="{[$style['node-item']]: true}"
@click.stop="onClick"
:class="$style.nodeItem"
:description="allowActions ? undefined : description"
:title="displayName"
:isTrigger="!allowActions && isTriggerNode"
:show-action-arrow="showActionArrow"
>
<node-icon :class="$style['node-icon']" :nodeType="nodeType" />
<div>
<div :class="$style.details">
<span :class="$style.name" data-test-id="node-item-name">
{{ $locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: nodeType.displayName,
})
}}
</span>
<span v-if="isTrigger" :class="$style['trigger-icon']">
<trigger-icon />
</span>
<n8n-tooltip v-if="isCommunityNode" placement="top" data-test-id="node-item-community-tooltip">
<template #content>
<div
:class="$style['community-node-icon']"
v-html="$locale.baseText('generic.communityNode.tooltip', { interpolate: { packageName: nodeType.name.split('.')[0], docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL } })"
@click="onCommunityNodeTooltipClick"
>
</div>
</template>
<n8n-icon icon="cube" />
</n8n-tooltip>
</div>
<div :class="$style.description">
{{ $locale.headerText({
key: `headers.${shortNodeType}.description`,
fallback: nodeType.description,
})
}}
</div>
<template #icon>
<node-icon :nodeType="nodeType" />
</template>
<div :class="$style['draggable-data-transfer']" ref="draggableDataTransfer" />
<transition name="node-item-transition">
<div
:class="$style.draggable"
:style="draggableStyle"
ref="draggable"
v-show="dragging"
>
<node-icon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" />
</div>
</transition>
</div>
</div>
<template #tooltip v-if="isCommunityNode">
<p
:class="$style.communityNodeIcon"
v-html="$locale.baseText('generic.communityNode.tooltip', { interpolate: { packageName: nodeType.name.split('.')[0], docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL } })"
@click="onCommunityNodeTooltipClick"
/>
</template>
<template #dragContent>
<div :class="$style.draggableDataTransfer" ref="draggableDataTransfer"/>
<div
:class="$style.draggable"
:style="draggableStyle"
v-show="dragging"
>
<node-icon :nodeType="nodeType" @click.capture.stop :size="40" :shrink="false" />
</div>
</template>
</n8n-node-creator-node>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
<script setup lang="ts">
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
import { INodeTypeDescription } from 'n8n-workflow';
import { isCommunityPackageName } from '@/utils';
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
import { isCommunityPackageName } from '@/utils';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import NodeIcon from '@/components/NodeIcon.vue';
import TriggerIcon from '@/components/TriggerIcon.vue';
Vue.component('node-icon', NodeIcon);
Vue.component('trigger-icon', TriggerIcon);
export interface Props {
nodeType: INodeTypeDescription;
active?: boolean;
allowActions?: boolean;
}
export default Vue.extend({
name: 'NodeItem',
props: {
nodeType: {
type: Object as PropType<INodeTypeDescription>,
},
active: {
type: Boolean,
},
},
data() {
return {
dragging: false,
draggablePosition: {
x: -100,
y: -100,
},
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
};
},
computed: {
shortNodeType(): string {
return this.$locale.shortNodeType(this.nodeType.name);
},
isTrigger (): boolean {
return this.nodeType.group.includes('trigger');
},
draggableStyle(): { top: string; left: string; } {
return {
top: `${this.draggablePosition.y}px`,
left: `${this.draggablePosition.x}px`,
};
},
isCommunityNode(): boolean {
return isCommunityPackageName(this.nodeType.name);
},
},
methods: {
onDragStart(event: DragEvent): void {
/**
* Workaround for firefox, that doesn't attach the pageX and pageY coordinates to "ondrag" event.
* All browsers attach the correct page coordinates to the "dragover" event.
* @bug https://bugzilla.mozilla.org/show_bug.cgi?id=505521
*/
document.body.addEventListener("dragover", this.onDragOver);
const { pageX: x, pageY: y } = event;
this.$emit('dragstart', event);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.dropEffect = "copy";
event.dataTransfer.setData('nodeTypeName', this.nodeType.name);
event.dataTransfer.setDragImage(this.$refs.draggableDataTransfer as Element, 0, 0);
}
this.dragging = true;
this.draggablePosition = { x, y };
},
onDragOver(event: DragEvent): void {
if (!this.dragging || event.pageX === 0 && event.pageY === 0) {
return;
}
const [x,y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
this.draggablePosition = { x, y };
},
onDragEnd(event: DragEvent): void {
document.body.removeEventListener("dragover", this.onDragOver);
this.$emit('dragend', event);
this.dragging = false;
setTimeout(() => {
this.draggablePosition = { x: -100, y: -100 };
}, 300);
},
onCommunityNodeTooltipClick(event: MouseEvent) {
if ((event.target as Element).localName === 'a') {
this.$telemetry.track('user clicked cnr docs link', { source: 'nodes panel node' });
}
},
},
const props = withDefaults(defineProps<Props>(), {
active: false,
allowActions: false,
});
</script>
const emit = defineEmits<{
(event: 'dragstart', $e: DragEvent): void,
(event: 'dragend', $e: DragEvent): void,
(event: 'nodeTypeSelected', value: string[]): void,
(event: 'actionsOpen', value: INodeTypeDescription): void,
}>();
const instance = getCurrentInstance();
const state = reactive({
dragging: false,
draggablePosition: {
x: -100,
y: -100,
},
draggableDataTransfer: null as Element | null,
});
const description = computed<string>(() => {
return instance?.proxy.$locale.headerText({
key: `headers.${shortNodeType.value}.description`,
fallback: props.nodeType.description,
}) as string;
});
const showActionArrow = computed(() => props.allowActions && hasActions.value);
const hasActions = computed<boolean>(() => (props.nodeType.actions?.length || 0) > 0);
const shortNodeType = computed<string>(() => instance?.proxy.$locale.shortNodeType(props.nodeType.name) || '');
const draggableStyle = computed<{ top: string; left: string; }>(() => ({
top: `${state.draggablePosition.y}px`,
left: `${state.draggablePosition.x}px`,
}));
const isCommunityNode = computed<boolean>(() => isCommunityPackageName(props.nodeType.name));
const displayName = computed<any>(() => {
const displayName = props.nodeType.displayName.trimEnd();
return instance?.proxy.$locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: props.allowActions ? displayName.replace('Trigger', '') : displayName,
});
});
const isTriggerNode = computed<boolean>(() => props.nodeType.displayName.toLowerCase().includes('trigger'));
function onClick() {
if(hasActions.value && props.allowActions) emit('actionsOpen', props.nodeType);
else emit('nodeTypeSelected', [props.nodeType.name]);
}
function onDragStart(event: DragEvent): void {
/**
* Workaround for firefox, that doesn't attach the pageX and pageY coordinates to "ondrag" event.
* All browsers attach the correct page coordinates to the "dragover" event.
* @bug https://bugzilla.mozilla.org/show_bug.cgi?id=505521
*/
document.body.addEventListener("dragover", onDragOver);
const { pageX: x, pageY: y } = event;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.dropEffect = "copy";
event.dataTransfer.setDragImage(state.draggableDataTransfer as Element, 0, 0);
event.dataTransfer.setData(
'nodeTypeName',
useNodeCreatorStore().getNodeTypesWithManualTrigger(props.nodeType.name).join(','),
);
}
state.dragging = true;
state.draggablePosition = { x, y };
emit('dragstart', event);
}
function onDragOver(event: DragEvent): void {
if (!state.dragging || event.pageX === 0 && event.pageY === 0) {
return;
}
const [x,y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
state.draggablePosition = { x, y };
}
function onDragEnd(event: DragEvent): void {
document.body.removeEventListener("dragover", onDragOver);
emit('dragend', event);
state.dragging = false;
setTimeout(() => {
state.draggablePosition = { x: -100, y: -100 };
}, 300);
}
function onCommunityNodeTooltipClick(event: MouseEvent) {
if ((event.target as Element).localName === 'a') {
instance?.proxy.$telemetry.track('user clicked cnr docs link', { source: 'nodes panel node' });
}
}
defineExpose({
onClick,
});
const { dragging, draggableDataTransfer } = toRefs(state);
</script>
<style lang="scss" module>
.node-item {
padding: 11px 8px 11px 0;
.nodeItem {
--trigger-icon-background-color: #{$trigger-icon-background-color};
--trigger-icon-border-color: #{$trigger-icon-border-color};
margin-left: 15px;
margin-right: 12px;
display: flex;
cursor: grab;
user-select: none;
}
.details {
align-items: center;
}
.node-icon {
min-width: 26px;
max-width: 26px;
margin-right: 15px;
}
.name {
font-weight: var(--font-weight-bold);
font-size: 14px;
line-height: 18px;
margin-right: 5px;
}
.packageName {
margin-right: 5px;
}
.description {
margin-top: 2px;
font-size: var(--font-size-2xs);
line-height: 16px;
font-weight: 400;
color: $node-creator-description-color;
}
.trigger-icon {
height: 16px;
width: 16px;
display: inline-block;
margin-right: var(--spacing-3xs);
vertical-align: middle;
}
.community-node-icon {
.communityNodeIcon {
vertical-align: top;
}
@ -218,29 +192,8 @@ export default Vue.extend({
align-items: center;
}
.draggable-data-transfer {
.draggableDataTransfer {
width: 1px;
height: 1px;
}
</style>
<style lang="scss" scoped>
.node-item-transition {
&-enter-active,
&-leave-active {
transition-property: opacity, transform;
transition-duration: 300ms;
transition-timing-function: ease;
}
&-enter,
&-leave-to {
opacity: 0;
transform: scale(0);
}
}
.el-tooltip svg {
color: var(--color-foreground-xdark);
}
</style>

View file

@ -5,12 +5,14 @@
</div>
<div :class="$style.text">
<input
:placeholder="$locale.baseText('nodeCreator.searchBar.searchNodes')"
ref="input"
:placeholder="placeholder"
:value="value"
@input="onInput"
:class="$style.input"
ref="inputRef"
autofocus
data-test-id="node-creator-search-bar"
tabindex="0"
/>
</div>
<div :class="$style.suffix" v-if="value.length > 0" @click="clear">
@ -21,50 +23,56 @@
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
import mixins from 'vue-typed-mixins';
<script setup lang="ts">
import Vue, { onMounted, reactive, toRefs, onBeforeUnmount } from 'vue';
import { externalHooks } from '@/mixins/externalHooks';
export default mixins(externalHooks).extend({
name: "SearchBar",
props: {
value: {
type: String,
},
eventBus: {
type: Object as PropType<Vue>,
},
},
mounted() {
if (this.eventBus) {
this.eventBus.$on("focus", this.focus);
}
setTimeout(this.focus, 0);
export interface Props {
placeholder: string;
value: string;
eventBus?: Vue;
}
this.$externalHooks().run('nodeCreator_searchBar.mount', { inputRef: this.$refs['input'] });
},
methods: {
focus() {
const input = this.$refs.input as HTMLInputElement;
if (input) {
input.focus();
}
},
onInput(event: InputEvent) {
const input = event.target as HTMLInputElement;
this.$emit("input", input.value);
},
clear() {
this.$emit("input", "");
},
},
beforeDestroy() {
if (this.eventBus) {
this.eventBus.$off("focus", this.focus);
}
},
withDefaults(defineProps<Props>(), {
placeholder: '',
value: '',
});
const emit = defineEmits<{
(event: 'input', value: string): void,
}>();
const { $externalHooks } = new externalHooks();
const state = reactive({
inputRef: null as HTMLInputElement | null,
});
function focus() {
state.inputRef?.focus();
}
function onInput(event: Event) {
const input = event.target as HTMLInputElement;
emit("input", input.value);
}
function clear() {
emit("input", "");
}
onMounted(() => {
$externalHooks().run('nodeCreator_searchBar.mount', { inputRef: state.inputRef });
setTimeout(focus, 0);
});
onBeforeUnmount(() => {
state.inputRef?.remove();
});
const { inputRef } = toRefs(state);
defineExpose({
focus,
});
</script>
@ -74,7 +82,7 @@ export default mixins(externalHooks).extend({
height: 40px;
padding: var(--spacing-s) var(--spacing-xs);
align-items: center;
margin: var(--spacing-s);
margin: var(--search-margin, var(--spacing-s));
filter: drop-shadow(0px 2px 5px rgba(46, 46, 50, 0.04));
border: 1px solid $node-creator-border-color;

View file

@ -56,6 +56,7 @@ export default Vue.extend({
.subcategory {
display: flex;
padding: 11px 16px 11px 30px;
user-select: none;
}
.subcategoryWithIcon {

View file

@ -1,167 +1,364 @@
<template>
<div :class="{ [$style.triggerHelperContainer]: true, [$style.isRoot]: isRoot }">
<categorized-items
ref="categorizedItems"
@subcategoryClose="onSubcategoryClose"
@onSubcategorySelected="onSubcategorySelected"
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
:expandAllCategories="isActionsActive"
:subcategoryOverride="nodeAppSubcategory"
:alwaysShowSearch="isActionsActive"
:categorizedItems="computedCategorizedItems"
:categoriesWithNodes="computedCategoriesWithNodes"
:initialActiveIndex="0"
:searchItems="searchItems"
:firstLevelItems="isRoot ? items : []"
:excludedCategories="isRoot ? [] : [CORE_NODES_CATEGORY]"
:initialActiveCategories="[COMMUNICATION_CATEGORY]"
:withActionsGetter="shouldShowNodeActions"
:firstLevelItems="firstLevelItems"
:showSubcategoryIcon="isActionsActive"
:flatten="!isActionsActive && isAppEventSubcategory"
:filterByType="false"
:lazyRender="true"
:allItems="allNodes"
:searchPlaceholder="searchPlaceholder"
ref="categorizedItemsRef"
@subcategoryClose="onSubcategoryClose"
@onSubcategorySelected="onSubcategorySelected"
@nodeTypeSelected="onNodeTypeSelected"
@actionsOpen="setActiveActionsNodeType"
@actionSelected="onActionSelected"
>
<template #noResultsTitle v-if="isActionsActive">
<i />
</template>
<template #noResultsAction v-if="isActionsActive">
<p v-if="containsAPIAction" v-html="getCustomAPICallHintLocale('apiCallNoResult')" class="clickable" @click.stop="addHttpNode" />
<p v-else v-text="$locale.baseText('nodeCreator.noResults.noMatchingActions')"/>
</template>
<template #header>
<slot name="header" />
<p v-if="isRoot" v-text="$locale.baseText('nodeCreator.triggerHelperPanel.title')" :class="$style.title" />
<p
v-if="isRoot"
v-text="$locale.baseText('nodeCreator.triggerHelperPanel.title')"
:class="$style.title"
/>
</template>
<template #footer v-if="(activeNodeActions && containsAPIAction)">
<slot name="footer" />
<span v-html="getCustomAPICallHintLocale('apiCall')" class="clickable" @click.stop="addHttpNode" />
</template>
</categorized-items>
</div>
</template>
<script lang="ts">
import { PropType } from 'vue';
import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/mixins/externalHooks';
import { INodeCreateElement } from '@/Interface';
import { CORE_NODES_CATEGORY, WEBHOOK_NODE_TYPE, OTHER_TRIGGER_NODES_SUBCATEGORY, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, COMMUNICATION_CATEGORY, SCHEDULE_TRIGGER_NODE_TYPE } from '@/constants';
import ItemIterator from './ItemIterator.vue';
<script setup lang="ts">
import { reactive, toRefs, getCurrentInstance, computed, onMounted, ref } from 'vue';
import { INodeTypeDescription, INodeActionTypeDescription, INodeTypeNameVersion } from 'n8n-workflow';
import { INodeCreateElement, IActionItemProps, SubcategoryCreateElement, IUpdateInformation } from '@/Interface';
import { CORE_NODES_CATEGORY, WEBHOOK_NODE_TYPE, OTHER_TRIGGER_NODES_SUBCATEGORY, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, SCHEDULE_TRIGGER_NODE_TYPE, EMAIL_IMAP_NODE_TYPE, CUSTOM_API_CALL_NAME, HTTP_REQUEST_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import CategorizedItems from './CategorizedItems.vue';
import SearchBar from './SearchBar.vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { getCategoriesWithNodes, getCategorizedList } from "@/utils";
import { externalHooks } from '@/mixins/externalHooks';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { BaseTextKey } from '@/plugins/i18n';
export default mixins(externalHooks).extend({
name: 'TriggerHelperPanel',
components: {
ItemIterator,
CategorizedItems,
SearchBar,
},
props: {
searchItems: {
type: Array as PropType<INodeCreateElement[] | null>,
const instance = getCurrentInstance();
const items: INodeCreateElement[] = [{
key: "*",
type: "subcategory",
title: instance?.proxy.$locale.baseText('nodeCreator.subcategoryNames.appTriggerNodes'),
properties: {
subcategory: "App Trigger Nodes",
description: instance?.proxy.$locale.baseText('nodeCreator.subcategoryDescriptions.appTriggerNodes'),
icon: "fa:satellite-dish",
defaults: {
color: "#7D838F",
},
},
},
data() {
return {
CORE_NODES_CATEGORY,
COMMUNICATION_CATEGORY,
isRoot: true,
};
},
computed: {
items() {
return [{
key: "*",
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: SCHEDULE_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
{
key: SCHEDULE_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: SCHEDULE_TRIGGER_NODE_TYPE,
displayName: this.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName'),
description: this.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDescription'),
icon: "fa:clock",
defaults: {
color: "#7D838F",
},
},
},
group: [],
name: SCHEDULE_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName'),
description: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDescription'),
icon: "fa:clock",
defaults: {
color: "#7D838F",
},
{
key: WEBHOOK_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: WEBHOOK_NODE_TYPE,
displayName: 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);
{
key: WEBHOOK_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: WEBHOOK_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDisplayName'),
description: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDescription'),
iconData: {
type: "file",
icon: "webhook",
fileBuffer: "/static/webhook-icon.svg",
},
defaults: {
color: "#7D838F",
},
},
},
},
{
key: MANUAL_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: MANUAL_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
description: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDescription'),
icon: "fa:mouse-pointer",
defaults: {
color: "#7D838F",
},
},
},
},
{
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDisplayName'),
description: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDescription'),
icon: "fa:sign-out-alt",
defaults: {
color: "#7D838F",
},
},
},
},
{
type: "subcategory",
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: OTHER_TRIGGER_NODES_SUBCATEGORY,
description: instance?.proxy.$locale.baseText('nodeCreator.subcategoryDescriptions.otherTriggerNodes'),
icon: "fa:folder-open",
defaults: {
color: "#7D838F",
},
},
},
];
const emit = defineEmits({
"nodeTypeSelected": (nodeTypes: string[]) => true,
});
const state = reactive({
isRoot: true,
selectedSubcategory: '',
activeNodeActions: null as INodeTypeDescription | null,
latestNodeData: null as INodeTypeDescription | null,
});
const categorizedItemsRef = ref<InstanceType<typeof CategorizedItems>>();
const { $externalHooks } = new externalHooks();
const {
mergedAppNodes,
setShowTabs,
getActionData,
getNodeTypesWithManualTrigger,
setAddedNodeActionParameters,
} = useNodeCreatorStore();
const telemetry = instance?.proxy.$telemetry;
const { categorizedItems: allNodes, isTriggerNode } = useNodeTypesStore();
const containsAPIAction = computed(() => state.latestNodeData?.properties.some((p) => p.options?.find((o) => o.name === CUSTOM_API_CALL_NAME)) === true);
const computedCategorizedItems = computed(() => getCategorizedList(computedCategoriesWithNodes.value, true));
const nodeAppSubcategory = computed<(SubcategoryCreateElement | undefined)>(() => {
if(!state.activeNodeActions) return undefined;
return {
type: 'subcategory',
properties: {
subcategory: state.activeNodeActions.displayName,
nodeType: {
description: '',
key: state.activeNodeActions.name,
iconUrl: state.activeNodeActions.iconUrl,
icon: state.activeNodeActions.icon,
},
},
};
});
const searchPlaceholder = computed(() => {
const nodeNameTitle = state.activeNodeActions?.displayName?.trim() as string;
const actionsSearchPlaceholder = instance?.proxy.$locale.baseText(
'nodeCreator.actionsCategory.searchActions',
{ interpolate: { nodeNameTitle }},
);
return isActionsActive.value ? actionsSearchPlaceholder : undefined;
});
const filteredMergedAppNodes = computed(() => {
const WHITELISTED_APP_CORE_NODES = [
EMAIL_IMAP_NODE_TYPE,
WEBHOOK_NODE_TYPE,
];
if(isAppEventSubcategory.value) return mergedAppNodes.filter(node => {
const isRegularNode = !isTriggerNode(node.name);
const isStickyNode = node.name === STICKY_NODE_TYPE;
const isCoreNode = node.codex?.categories?.includes(CORE_NODES_CATEGORY) && !WHITELISTED_APP_CORE_NODES.includes(node.name);
const hasActions = (node.actions || []).length > 0;
if(isRegularNode && !hasActions) return false;
return !isCoreNode && !isStickyNode;
});
return mergedAppNodes;
});
const computedCategoriesWithNodes = computed(() => {
if(!state.activeNodeActions) return getCategoriesWithNodes(filteredMergedAppNodes.value, []);
return getCategoriesWithNodes(selectedNodeActions.value, [], state.activeNodeActions.displayName) ;
});
const selectedNodeActions = computed<INodeActionTypeDescription[]>(() => state.activeNodeActions?.actions ?? []);
const isAppEventSubcategory = computed(() => state.selectedSubcategory === "*");
const isActionsActive = computed(() => state.activeNodeActions !== null);
const firstLevelItems = computed(() => isRoot.value ? items : []);
const isSearchActive = computed(() => useNodeCreatorStore().itemsFilter !== '');
const searchItems = computed<INodeCreateElement[]>(() => {
const sorted = state.activeNodeActions ? [...selectedNodeActions.value] : [...filteredMergedAppNodes.value];
sorted.sort((a, b) => {
const textA = a.displayName.toLowerCase();
const textB = b.displayName.toLowerCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
return sorted.map((nodeType) => ({
type: 'node',
category: '',
key: nodeType.name,
properties: {
nodeType,
subcategory: state.activeNodeActions ? state.activeNodeActions.displayName : '',
},
includedByTrigger: nodeType.group.includes('trigger'),
includedByRegular: !nodeType.group.includes('trigger'),
}));
});
function onNodeTypeSelected(nodeTypes: string[]) {
emit("nodeTypeSelected", nodeTypes.length === 1 ? getNodeTypesWithManualTrigger(nodeTypes[0]) : nodeTypes);
}
function getCustomAPICallHintLocale(key: string) {
if(!state.activeNodeActions) return '';
const nodeNameTitle = state.activeNodeActions.displayName;
return instance?.proxy.$locale.baseText(
`nodeCreator.actionsList.${key}` as BaseTextKey,
{ interpolate: { nodeNameTitle }},
);
}
// The nodes.json doesn't contain API CALL option so we need to fetch the node detail
// to determine if need to render the API CALL hint
async function fetchNodeDetails() {
if(!state.activeNodeActions) return;
const { getNodesInformation } = useNodeTypesStore();
const { version, name } = state.activeNodeActions;
const payload = {
name,
version: Array.isArray(version) ? version?.slice(-1)[0] : version,
} as INodeTypeNameVersion;
const nodesInfo = await getNodesInformation([payload], false);
state.latestNodeData = nodesInfo[0];
}
function setActiveActionsNodeType(nodeType: INodeTypeDescription | null) {
state.activeNodeActions = nodeType;
setShowTabs(false);
fetchNodeDetails();
if(nodeType) trackActionsView();
}
function onActionSelected(actionCreateElement: INodeCreateElement) {
const action = (actionCreateElement.properties as IActionItemProps).nodeType;
const actionUpdateData = getActionData(action);
emit('nodeTypeSelected', getNodeTypesWithManualTrigger(actionUpdateData.key));
setAddedNodeActionParameters(actionUpdateData, telemetry);
}
function addHttpNode() {
const updateData = {
name: '',
key: HTTP_REQUEST_NODE_TYPE,
value: {
authentication: "predefinedCredentialType",
},
} as IUpdateInformation;
emit('nodeTypeSelected', [MANUAL_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
setAddedNodeActionParameters(updateData, telemetry, false);
const app_identifier = state.activeNodeActions?.name;
$externalHooks().run('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
}
function onSubcategorySelected(subcategory: INodeCreateElement) {
state.isRoot = false;
state.selectedSubcategory = subcategory.key;
}
function onSubcategoryClose(activeSubcategories: INodeCreateElement[]) {
if(isActionsActive.value === true) setActiveActionsNodeType(null);
state.isRoot = activeSubcategories.length === 0;
state.selectedSubcategory = activeSubcategories[activeSubcategories.length - 1]?.key ?? '';
}
function shouldShowNodeActions(node: INodeCreateElement) {
if(isAppEventSubcategory.value) return true;
if(state.isRoot && !isSearchActive.value) return false;
// Do not show actions for core category when searching
if(node.type === 'node') return !node.properties.nodeType.codex?.categories?.includes(CORE_NODES_CATEGORY);
return false;
}
function trackActionsView() {
const trigger_action_count = selectedNodeActions.value
.filter((action) => action.name.toLowerCase().includes('trigger')).length;
const trackingPayload = {
app_identifier: state.activeNodeActions?.name,
actions: selectedNodeActions.value.map(action => action.displayName),
regular_action_count: selectedNodeActions.value.length - trigger_action_count,
trigger_action_count,
};
$externalHooks().run('nodeCreateList.onViewActions', trackingPayload);
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload);
}
const { isRoot, activeNodeActions } = toRefs(state);
</script>
<style lang="scss" module>

View file

@ -1,6 +1,6 @@
<template>
<div class="type-selector" v-if="showTabs" data-test-id="node-creator-type-selector">
<el-tabs stretch :value="selectedType" @input="setType">
<div class="type-selector" v-if="nodeCreatorStore.showTabs" data-test-id="node-creator-type-selector">
<el-tabs stretch :value="nodeCreatorStore.selectedType" @input="nodeCreatorStore.setSelectedType">
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.all')" :name="ALL_NODE_FILTER"></el-tab-pane>
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.regular')" :name="REGULAR_NODE_FILTER"></el-tab-pane>
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.trigger')" :name="TRIGGER_NODE_FILTER"></el-tab-pane>
@ -8,39 +8,11 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { ALL_NODE_FILTER, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER } from '@/constants';
import { INodeFilterType } from '@/Interface';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { mapStores } from 'pinia';
import Vue from 'vue';
export default Vue.extend({
name: 'NodeCreateTypeSelector',
data() {
return {
REGULAR_NODE_FILTER,
TRIGGER_NODE_FILTER,
ALL_NODE_FILTER,
};
},
methods: {
setType(type: INodeFilterType) {
this.nodeCreatorStore.selectedType = type;
},
},
computed: {
...mapStores(
useNodeCreatorStore,
),
showTabs(): boolean {
return this.nodeCreatorStore.showTabs;
},
selectedType(): string {
return this.nodeCreatorStore.selectedType;
},
},
});
const nodeCreatorStore = useNodeCreatorStore();
</script>
<style lang="scss" scoped>
::v-deep .el-tabs__item {
@ -58,9 +30,9 @@ export default Vue.extend({
.type-selector {
text-align: center;
background-color: $node-creator-select-background-color;
::v-deep .el-tabs > div {
margin-bottom: 0;
z-index: 1;
.el-tabs__nav {
height: 43px;

View file

@ -816,6 +816,8 @@ export default mixins(externalHooks, nodeHelpers).extend({
this.openPanel = 'settings';
});
}
this.updateNodeParameterIssues(this.node as INodeUi, this.nodeType);
},
});
</script>

View file

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

View file

@ -0,0 +1,59 @@
/**
* Creates event listeners for `data-action` attribute to allow for actions to be called from locale without using
* unsafe onclick attribute
*/
import { reactive, del, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue';
const state = reactive({
customActions: {} as Record<string, Function>,
});
export default () => {
function registerCustomAction(key: string, action: Function) {
state.customActions[key] = action;
}
function unregisterCustomAction(key: string) {
del(state.customActions, key);
}
function delegateClick(e: MouseEvent) {
const clickedElement = e.target;
if (!(clickedElement instanceof Element) || clickedElement.tagName !== 'A') return;
const actionAttribute = clickedElement.getAttribute('data-action');
if(actionAttribute && typeof availableActions.value[actionAttribute] === 'function') {
e.preventDefault();
availableActions.value[actionAttribute]();
}
}
function reload() {
if (window.top) {
window.top.location.reload();
} else {
window.location.reload();
}
}
const availableActions = computed<{[key: string]: Function}>(() => ({
reload,
...state.customActions,
}));
onMounted(() => {
const instance = getCurrentInstance();
window.addEventListener('click', delegateClick);
instance?.proxy.$root.$on('registerGlobalLinkAction', registerCustomAction);
});
onUnmounted(() => {
const instance = getCurrentInstance();
window.removeEventListener('click', delegateClick);
instance?.proxy.$root.$off('registerGlobalLinkAction', registerCustomAction);
});
return {
registerCustomAction,
unregisterCustomAction,
};
};

View file

@ -7,6 +7,7 @@ export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]'
// parameter input
export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
export const CUSTOM_API_CALL_NAME = 'Custom API Call';
// workflows
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
@ -79,10 +80,12 @@ export const CRON_NODE_TYPE = 'n8n-nodes-base.cron';
export const CLEARBIT_NODE_TYPE = 'n8n-nodes-base.clearbit';
export const FUNCTION_NODE_TYPE = 'n8n-nodes-base.function';
export const GITHUB_TRIGGER_NODE_TYPE = 'n8n-nodes-base.githubTrigger';
export const GIT_NODE_TYPE = 'n8n-nodes-base.git';
export const GOOGLE_SHEETS_NODE_TYPE = 'n8n-nodes-base.googleSheets';
export const ERROR_TRIGGER_NODE_TYPE = 'n8n-nodes-base.errorTrigger';
export const ELASTIC_SECURITY_NODE_TYPE = 'n8n-nodes-base.elasticSecurity';
export const EMAIL_SEND_NODE_TYPE = 'n8n-nodes-base.emailSend';
export const EMAIL_IMAP_NODE_TYPE = 'n8n-nodes-base.emailReadImap';
export const EXECUTE_COMMAND_NODE_TYPE = 'n8n-nodes-base.executeCommand';
export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest';
export const HUBSPOT_TRIGGER_NODE_TYPE = 'n8n-nodes-base.hubspotTrigger';
@ -139,6 +142,7 @@ export const PIN_DATA_NODE_TYPES_DENYLIST = [
export const CORE_NODES_CATEGORY = 'Core Nodes';
export const COMMUNICATION_CATEGORY = 'Communication';
export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
export const RECOMMENDED_CATEGORY = 'Recommended';
export const SUBCATEGORY_DESCRIPTIONS: {
[category: string]: { [subcategory: string]: string };
} = {

View file

@ -1,55 +0,0 @@
/**
* Creates event listeners for `data-action` attribute to allow for actions to be called from locale without using
* unsafe onclick attribute
*/
import Vue from 'vue';
export const globalLinkActions = Vue.extend({
data(): {[key: string]: {[key: string]: Function}} {
return {
customActions: {},
};
},
mounted() {
window.addEventListener('click', this.delegateClick);
this.$root.$on('registerGlobalLinkAction', this.registerCustomAction);
},
destroyed() {
window.removeEventListener('click', this.delegateClick);
this.$root.$off('registerGlobalLinkAction', this.registerCustomAction);
},
computed: {
availableActions(): {[key: string]: Function} {
return {
reload: this.reload,
...this.customActions,
};
},
},
methods: {
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;
const actionAttribute = clickedElement.getAttribute('data-action');
if(actionAttribute && typeof this.availableActions[actionAttribute] === 'function') {
e.preventDefault();
this.availableActions[actionAttribute]();
}
},
reload() {
if (window.top) {
window.top.location.reload();
} else {
window.location.reload();
}
},
},
});

View file

@ -661,6 +661,13 @@
"node.discovery.pinData.canvas": "You can pin this output instead of waiting for a test event. Open node to do so.",
"node.discovery.pinData.ndv": "You can pin this output instead of waiting for a test event.",
"nodeBase.clickToAddNodeOrDragToConnect": "Click to add node<br />or drag to connect",
"nodeCreator.actionsCategory.operations": "Operations",
"nodeCreator.actionsCategory.onNewEvent": "On new {event} event",
"nodeCreator.actionsCategory.onEvent": "On {event}",
"nodeCreator.actionsCategory.recommended": "Recommended",
"nodeCreator.actionsCategory.searchActions": "Search {nodeNameTitle} Actions...",
"nodeCreator.actionsList.apiCall": "Didn't find the right event? Make a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>",
"nodeCreator.actionsList.apiCallNoResult": "Nothing found — try making a <a data-action='addHttpNode'>custom {nodeNameTitle} API call</a>",
"nodeCreator.categoryNames.analytics": "Analytics",
"nodeCreator.categoryNames.communication": "Communication",
"nodeCreator.categoryNames.coreNodes": "Core Nodes",
@ -684,7 +691,8 @@
"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.noMatchingActions": "No actions matching your results",
"nodeCreator.noResults.clickToSeeResults": "To see all 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",

View file

@ -12,7 +12,6 @@ import { useSettingsStore } from "@/stores/settings";
import { useRootStore } from "@/stores/n8nRootStore";
export class Telemetry {
private pageEventQueue: Array<{route: Route}>;
private previousPath: string;
@ -153,14 +152,28 @@ export class Telemetry {
break;
case 'nodeCreateList.onCategoryExpanded':
properties.is_subcategory = false;
properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
this.track('User viewed node category', properties);
break;
case 'nodeCreateList.onViewActions':
properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
this.track('User viewed node actions', properties);
break;
case 'nodeCreateList.onActionsCustmAPIClicked':
properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
this.track('User clicked custom API from node actions', properties);
break;
case 'nodeCreateList.addAction':
properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
this.track('User added action', properties);
break;
case 'nodeCreateList.onSubcategorySelected':
const selectedProperties = (properties.selected as IDataObject).properties as IDataObject;
if(selectedProperties && selectedProperties.subcategory) {
properties.category_name = selectedProperties.subcategory;
}
properties.is_subcategory = true;
properties.nodes_panel_session_id = this.userNodesPanelSession.sessionId;
delete properties.selected;
this.track('User viewed node category', properties);
break;

View file

@ -18,4 +18,11 @@ declare global {
[elem: string]: any;
}
}
interface Array<T> {
findLast(
predicate: (value: T, index: number, obj: T[]) => unknown,
thisArg?: any
): T
}
}

View file

@ -1,6 +1,192 @@
import { ALL_NODE_FILTER, STORES } from "@/constants";
import { INodeCreatorState } from "@/Interface";
import startCase from 'lodash.startCase';
import { defineStore } from "pinia";
import { INodePropertyCollection, INodePropertyOptions, IDataObject, INodeProperties, INodeTypeDescription, deepCopy, INodeParameters, INodeActionTypeDescription } from 'n8n-workflow';
import { STORES, MANUAL_TRIGGER_NODE_TYPE, CORE_NODES_CATEGORY, CALENDLY_TRIGGER_NODE_TYPE, TRIGGER_NODE_FILTER } from "@/constants";
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useWorkflowsStore } from './workflows';
import { CUSTOM_API_CALL_KEY, ALL_NODE_FILTER } from '@/constants';
import { INodeCreatorState, INodeFilterType, IUpdateInformation } from '@/Interface';
import { i18n } from '@/plugins/i18n';
import { externalHooks } from '@/mixins/externalHooks';
import { Telemetry } from '@/plugins/telemetry';
const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
const customNodeActionsParsers: {[key: string]: (matchedProperty: INodeProperties, nodeTypeDescription: INodeTypeDescription) => INodeActionTypeDescription[] | undefined} = {
['n8n-nodes-base.hubspotTrigger']: (matchedProperty, nodeTypeDescription) => {
const collection = matchedProperty?.options?.[0] as INodePropertyCollection;
return (collection?.values[0]?.options as INodePropertyOptions[])?.map((categoryItem): INodeActionTypeDescription => ({
...getNodeTypeBase(nodeTypeDescription, i18n.baseText('nodeCreator.actionsCategory.recommended')),
actionKey: categoryItem.value as string,
displayName: i18n.baseText('nodeCreator.actionsCategory.onEvent', {
interpolate: {event: startCase(categoryItem.name)},
}),
description: categoryItem.description || '',
displayOptions: matchedProperty.displayOptions,
values: { eventsUi: { eventValues: [{ name: categoryItem.value }] } },
}));
},
};
function filterSinglePlaceholderAction(actions: INodeActionTypeDescription[]) {
return actions.filter((action: INodeActionTypeDescription, _: number, arr: INodeActionTypeDescription[]) => {
const isPlaceholderTriggerAction = action.actionKey === PLACEHOLDER_RECOMMENDED_ACTION_KEY;
return !isPlaceholderTriggerAction || (isPlaceholderTriggerAction && arr.length > 1);
});
}
function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, category: string) {
return {
name: nodeTypeDescription.name,
group: ['trigger'],
codex: {
categories: [category],
subcategories: {
[nodeTypeDescription.displayName]: [category],
},
},
iconUrl: nodeTypeDescription.iconUrl,
icon: nodeTypeDescription.icon,
version: [1],
defaults: {},
inputs: [],
outputs: [],
properties: [],
};
}
function operationsCategory(nodeTypeDescription: INodeTypeDescription): INodeActionTypeDescription[] {
if (!!nodeTypeDescription.properties.find((property) => property.name === 'resource')) return [];
const matchedProperty = nodeTypeDescription.properties
.find((property) =>property.name?.toLowerCase() === 'operation');
if (!matchedProperty || !matchedProperty.options) return [];
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
);
const items = filteredOutItems.map((item: INodePropertyOptions) => ({
...getNodeTypeBase(nodeTypeDescription, i18n.baseText('nodeCreator.actionsCategory.operations')),
actionKey: item.value as string,
displayName: item.action ?? startCase(item.name),
description: item.description ?? '',
displayOptions: matchedProperty.displayOptions,
values: {
[matchedProperty.name]: matchedProperty.type === 'multiOptions' ? [item.value] : item.value,
},
}));
// Do not return empty category
if (items.length === 0) return [];
return items;
}
function recommendedCategory(nodeTypeDescription: INodeTypeDescription): INodeActionTypeDescription[] {
const matchingKeys = ['event', 'events', 'trigger on'];
const isTrigger = nodeTypeDescription.displayName?.toLowerCase().includes('trigger');
const matchedProperty = nodeTypeDescription.properties.find((property) =>
matchingKeys.includes(property.displayName?.toLowerCase()),
);
if (!isTrigger) return [];
// Inject placeholder action if no events are available
// so user is able to add node to the canvas from the actions panel
if (!matchedProperty || !matchedProperty.options) {
return [{
...getNodeTypeBase(nodeTypeDescription, i18n.baseText('nodeCreator.actionsCategory.recommended')),
actionKey: PLACEHOLDER_RECOMMENDED_ACTION_KEY,
displayName: i18n.baseText('nodeCreator.actionsCategory.onNewEvent', {
interpolate: {event: nodeTypeDescription.displayName.replace('Trigger', '').trimEnd()},
}),
description: '',
}];
}
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
);
const customParsedItem = customNodeActionsParsers[nodeTypeDescription.name]?.(matchedProperty, nodeTypeDescription);
const items =
customParsedItem ??
filteredOutItems.map((categoryItem: INodePropertyOptions) => ({
...getNodeTypeBase(nodeTypeDescription, i18n.baseText('nodeCreator.actionsCategory.recommended')),
actionKey: categoryItem.value as string,
displayName: i18n.baseText('nodeCreator.actionsCategory.onEvent', {
interpolate: {event: startCase(categoryItem.name)},
}),
description: categoryItem.description || '',
displayOptions: matchedProperty.displayOptions,
values: {
[matchedProperty.name]:
matchedProperty.type === 'multiOptions' ? [categoryItem.value] : categoryItem.value,
},
}));
return items;
}
function resourceCategories(nodeTypeDescription: INodeTypeDescription): INodeActionTypeDescription[] {
const transformedNodes: INodeActionTypeDescription[] = [];
const matchedProperties = nodeTypeDescription.properties.filter((property) =>property.displayName?.toLowerCase() === 'resource');
matchedProperties.forEach((property) => {
(property.options as INodePropertyOptions[] || [])
.filter((option) => option.value !== CUSTOM_API_CALL_KEY)
.forEach((resourceOption, i, options) => {
const isSingleResource = options.length === 1;
// Match operations for the resource by checking if displayOptions matches or contains the resource name
const operations = nodeTypeDescription.properties.find(
(operation) =>
operation.name === 'operation' &&
(operation.displayOptions?.show?.resource?.includes(resourceOption.value) ||
isSingleResource),
);
if (!operations?.options) return;
const items = (operations.options as INodePropertyOptions[] || []).map(
(operationOption) => {
const displayName =
operationOption.action ??
`${resourceOption.name} ${startCase(operationOption.name)}`;
// We need to manually populate displayOptions as they are not present in the node description
// if the resource has only one option
const displayOptions = isSingleResource
? { show: { resource: [(options as INodePropertyOptions[])[0]?.value] } }
: operations?.displayOptions;
return {
...getNodeTypeBase(nodeTypeDescription, resourceOption.name),
actionKey: operationOption.value as string,
description: operationOption?.description ?? '',
displayOptions,
values: {
operation:
operations?.type === 'multiOptions'
? [operationOption.value]
: operationOption.value,
},
displayName,
group: ['trigger'],
};
},
);
transformedNodes.push(...items);
});
});
return transformedNodes;
}
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
state: (): INodeCreatorState => ({
@ -9,4 +195,112 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
showScrim: false,
selectedType: ALL_NODE_FILTER,
}),
actions: {
setShowTabs(isVisible: boolean) {
this.showTabs = isVisible;
},
setShowScrim(isVisible: boolean) {
this.showScrim = isVisible;
},
setSelectedType(selectedNodeType: INodeFilterType) {
this.selectedType = selectedNodeType;
},
setFilter(search: string) {
this.itemsFilter = search;
},
setAddedNodeActionParameters (action: IUpdateInformation, telemetry?: Telemetry, track = true) {
const { $onAction: onWorkflowStoreAction } = useWorkflowsStore();
const storeWatcher = onWorkflowStoreAction(({ name, after, store: { setLastNodeParameters }, args }) => {
if (name !== 'addNode' || args[0].type !== action.key) return;
after(() => {
setLastNodeParameters(action);
if(track) this.trackActionSelected(action, telemetry);
storeWatcher();
});
});
return storeWatcher;
},
trackActionSelected (action: IUpdateInformation, telemetry?: Telemetry) {
const { $externalHooks } = new externalHooks();
const payload = {
node_type: action.key,
action: action.name,
resource: (action.value as INodeParameters).resource || '',
};
$externalHooks().run('nodeCreateList.addAction', payload);
telemetry?.trackNodesPanel('nodeCreateList.addAction', payload);
},
},
getters: {
visibleNodesWithActions(): INodeTypeDescription[] {
const nodes = deepCopy(useNodeTypesStore().visibleNodeTypes);
const nodesWithActions = nodes.map((node) => {
const isCoreNode = node.codex?.categories?.includes(CORE_NODES_CATEGORY);
// Core nodes shouldn't support actions
node.actions = [];
if(isCoreNode) return node;
node.actions.push(
...recommendedCategory(node),
...operationsCategory(node),
...resourceCategories(node),
);
return node;
});
return nodesWithActions;
},
mergedAppNodes(): INodeTypeDescription[] {
const mergedNodes = this.visibleNodesWithActions.reduce((acc: Record<string, INodeTypeDescription>, node: INodeTypeDescription) => {
const clonedNode = deepCopy(node);
const isCoreNode = node.codex?.categories?.includes(CORE_NODES_CATEGORY);
const actions = node.actions || [];
// Do not merge core nodes
const normalizedName = isCoreNode ? node.name : node.name.toLowerCase().replace('trigger', '');
const existingNode = acc[normalizedName];
if(existingNode) existingNode.actions?.push(...actions);
else acc[normalizedName] = clonedNode;
if(!isCoreNode) acc[normalizedName].displayName = node.displayName.replace('Trigger', '');
acc[normalizedName].actions = filterSinglePlaceholderAction(acc[normalizedName].actions || []);
return acc;
}, {});
return Object.values(mergedNodes);
},
getNodeTypesWithManualTrigger: () => (nodeType?: string): string[] => {
if(!nodeType) return [];
const { workflowTriggerNodes } = useWorkflowsStore();
const isTriggerAction = nodeType.toLocaleLowerCase().includes('trigger');
const workflowContainsTrigger = workflowTriggerNodes.length > 0;
const isTriggerPanel = useNodeCreatorStore().selectedType === TRIGGER_NODE_FILTER;
const nodeTypes = !isTriggerAction && !workflowContainsTrigger && isTriggerPanel
? [MANUAL_TRIGGER_NODE_TYPE, nodeType]
: [nodeType];
return nodeTypes;
},
getActionData: () => (actionItem: INodeActionTypeDescription): IUpdateInformation => {
const displayOptions = actionItem.displayOptions ;
const displayConditions = Object.keys(displayOptions?.show || {})
.reduce((acc: IDataObject, showCondition: string) => {
acc[showCondition] = displayOptions?.show?.[showCondition]?.[0];
return acc;
}, {});
return {
name: actionItem.displayName,
key: actionItem.name as string,
value: { ...actionItem.values , ...displayConditions} as INodeParameters,
};
},
},
});

View file

@ -9,7 +9,7 @@ import Vue from "vue";
import { useCredentialsStore } from "./credentials";
import { useRootStore } from "./n8nRootStore";
import { useUsersStore } from "./users";
import { useNodeCreatorStore } from './nodeCreator';
function getNodeVersions(nodeType: INodeTypeDescription) {
return Array.isArray(nodeType.version) ? nodeType.version : [nodeType.version];
}
@ -79,17 +79,21 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
}
for (const version of newNodeVersions) {
// Node exists with the same name
if (acc[newNodeType.name]) {
acc[newNodeType.name][version] = newNodeType;
acc[newNodeType.name][version] = Object.assign(acc[newNodeType.name][version] ?? {}, newNodeType);
} else {
acc[newNodeType.name] = { [version]: newNodeType };
acc[newNodeType.name] = Object.assign(acc[newNodeType.name] ?? {}, { [version]: newNodeType });
}
}
return acc;
}, { ...this.nodeTypes });
Vue.set(this, 'nodeTypes', nodeTypes);
// Trigger compute of mergedAppNodes getter so it's ready when user opens the node creator
// tslint:disable-next-line: no-unused-expression
useNodeCreatorStore().mergedAppNodes;
},
removeNodeTypes(nodeTypesToRemove: INodeTypeDescription[]): void {
this.nodeTypes = nodeTypesToRemove.reduce(
@ -97,7 +101,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
this.nodeTypes,
);
},
async getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
async getNodesInformation(nodeInfos: INodeTypeNameVersion[], replace = true): Promise<INodeTypeDescription[]> {
const rootStore = useRootStore();
const nodesInformation = await getNodesInformation(rootStore.getRestApiContext, nodeInfos);
@ -111,7 +115,9 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
);
}
});
this.setNodeTypes(nodesInformation);
if(replace) this.setNodeTypes(nodesInformation);
return nodesInformation;
},
async getFullNodesProperties(nodesToBeFetched: INodeTypeNameVersion[]): Promise<void> {
const credentialsStore = useCredentialsStore();

View file

@ -21,7 +21,7 @@ import {
IWorkflowsMap,
WorkflowsState,
} from "@/Interface";
import {defineStore} from "pinia";
import { defineStore } from "pinia";
import {
deepCopy,
IConnection,
@ -40,7 +40,7 @@ import {
} from 'n8n-workflow';
import Vue from "vue";
import {useRootStore} from "./n8nRootStore";
import { useRootStore } from "./n8nRootStore";
import {
getActiveWorkflows,
getCurrentExecutions,
@ -50,7 +50,7 @@ import {
} from "@/api/workflows";
import {useUIStore} from "./ui";
import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus";
import {isJsonKeyObject, getPairedItemsMapping, stringSizeInBytes} from "@/utils";
import {isJsonKeyObject, getPairedItemsMapping, stringSizeInBytes, isObjectLiteral} from "@/utils";
import {useNDVStore} from "./ndv";
import {useNodeTypesStore} from "./nodeTypes";
import {useWorkflowsEEStore} from "@/stores/workflows.ee";
@ -720,7 +720,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
Vue.set(node, updateInformation.key, updateInformation.value);
},
setNodeParameters(updateInformation: IUpdateInformation): void {
setNodeParameters(updateInformation: IUpdateInformation, append?: boolean): void {
// Find the node that should be updated
const node = this.workflow.nodes.find(node => {
return node.name === updateInformation.name;
@ -732,7 +732,11 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
const uiStore = useUIStore();
uiStore.stateIsDirty = true;
Vue.set(node, 'parameters', updateInformation.value);
const newParameters = !!append && isObjectLiteral(updateInformation.value)
? {...node.parameters, ...updateInformation.value }
: updateInformation.value;
Vue.set(node, 'parameters', newParameters);
if (!this.nodeMetadata[node.name]) {
Vue.set(this.nodeMetadata, node.name, {});
@ -740,6 +744,12 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
Vue.set(this.nodeMetadata[node.name], 'parametersLastUpdatedAt', Date.now());
},
setLastNodeParameters(updateInformation: IUpdateInformation) {
const latestNode = this.workflow.nodes.findLast((node) => node.type === updateInformation.key) as INodeUi;
if(latestNode) this.setNodeParameters({...updateInformation, name: latestNode.name}, true);
},
addNodeExecutionData(pushData: IPushDataNodeExecuteAfter): void {
if (this.workflowExecutionData === null || !this.workflowExecutionData.data) {
throw new Error('The "workflowExecutionData" is not initialized!');

View file

@ -1,5 +1,6 @@
import {
CORE_NODES_CATEGORY,
RECOMMENDED_CATEGORY,
CUSTOM_NODES_CATEGORY,
SUBCATEGORY_DESCRIPTIONS,
UNCATEGORIZED_CATEGORY,
@ -13,7 +14,7 @@ import {
MAPPING_PARAMS,
} from '@/constants';
import { INodeCreateElement, ICategoriesWithNodes, INodeUi, ITemplatesNode, INodeItemProps } from '@/Interface';
import { IDataObject, INodeExecutionData, INodeProperties, INodeTypeDescription, NodeParameterValueType } from 'n8n-workflow';
import { IDataObject, INodeExecutionData, INodeProperties, INodeTypeDescription, INodeActionTypeDescription, NodeParameterValueType } from 'n8n-workflow';
import { isResourceLocatorValue, isJsonKeyObject } from '@/utils';
/*
@ -25,7 +26,7 @@ const CRED_KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
const NODE_KEYWORDS_TO_FILTER = ['Trigger'];
const COMMUNITY_PACKAGE_NAME_REGEX = /(@\w+\/)?n8n-nodes-(?!base\b)\b\w+/g;
const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescription, category: string, subcategory: string) => {
const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescription | INodeActionTypeDescription, category: string, subcategory: string) => {
if (!accu[category]) {
accu[category] = {};
}
@ -44,7 +45,7 @@ const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescri
accu[category][subcategory].regularCount++;
}
accu[category][subcategory].nodes.push({
type: 'node',
type: nodeType.actionKey ? 'action' : 'node' ,
key: `${category}_${nodeType.name}`,
category,
properties: {
@ -56,30 +57,25 @@ const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescri
});
};
export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[], personalizedNodeTypes: string[]): ICategoriesWithNodes => {
export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[], personalizedNodeTypes: string[], uncategorizedSubcategory = UNCATEGORIZED_SUBCATEGORY): ICategoriesWithNodes => {
const sorted = [...nodeTypes].sort((a: INodeTypeDescription, b: INodeTypeDescription) => a.displayName > b.displayName? 1 : -1);
return sorted.reduce(
const result = sorted.reduce(
(accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => {
if (personalizedNodeTypes.includes(nodeType.name)) {
addNodeToCategory(accu, nodeType, PERSONALIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
addNodeToCategory(accu, nodeType, PERSONALIZED_CATEGORY, uncategorizedSubcategory);
}
if (!nodeType.codex || !nodeType.codex.categories) {
addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, uncategorizedSubcategory);
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;
const subcategories = nodeType?.codex?.subcategories?.[category] ?? null;
if(subcategories === null || subcategories.length === 0) {
addNodeToCategory(accu, nodeType, category, UNCATEGORIZED_SUBCATEGORY);
addNodeToCategory(accu, nodeType, category, uncategorizedSubcategory);
return;
}
@ -92,10 +88,11 @@ export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[], person
},
{},
);
return result;
};
const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
const excludeFromSort = [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, UNCATEGORIZED_CATEGORY, PERSONALIZED_CATEGORY];
const excludeFromSort = [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, UNCATEGORIZED_CATEGORY, PERSONALIZED_CATEGORY, RECOMMENDED_CATEGORY];
const categories = Object.keys(categoriesWithNodes);
const sorted = categories.filter(
(category: string) =>
@ -103,13 +100,13 @@ const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
);
sorted.sort();
return [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, PERSONALIZED_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY];
return [RECOMMENDED_CATEGORY, CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, PERSONALIZED_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY];
};
export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): INodeCreateElement[] => {
export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes, categoryIsExpanded = false): INodeCreateElement[] => {
const categories = getCategories(categoriesWithNodes);
return categories.reduce(
const result = categories.reduce(
(accu: INodeCreateElement[], category: string) => {
if (!categoriesWithNodes[category]) {
return accu;
@ -120,7 +117,7 @@ export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): I
key: category,
category,
properties: {
expanded: false,
expanded: categoryIsExpanded,
},
};
@ -170,6 +167,7 @@ export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): I
},
[],
);
return result;
};
export function getAppNameFromCredType(name: string) {

View file

@ -1,6 +1,6 @@
<template>
<div :class="$style.container" :style="containerCssVars" ref="container" data-test-id="canvas-add-button">
<n8n-tooltip placement="top" :value="showTooltip" manual :disabled="isScrimActive" :popper-class="$style.tooltip" :open-delay="700">
<div :class="$style.container" :style="containerCssVars" ref="container" data-test-id="canvas-add-button">
<n8n-tooltip placement="top" :value="showTooltip" manual :disabled="nodeCreatorStore.showScrim" :popper-class="$style.tooltip" :open-delay="700">
<button :class="$style.button" @click="$emit('click')" data-test-id="canvas-plus-button">
<font-awesome-icon icon="plus" size="lg" />
</button>
@ -12,38 +12,23 @@
</div>
</template>
<script lang="ts">
import Vue from 'vue';
<script setup lang="ts">
import { computed } from 'vue';
import { XYPosition } from '@/Interface';
import { mapStores } from 'pinia';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
export default Vue.extend({
name: 'CanvasAddButton',
props: {
showTooltip: {
type: Boolean,
},
position: {
type: Array,
},
},
computed: {
...mapStores(
useNodeCreatorStore,
),
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.nodeCreatorStore.showScrim;
},
},
});
export interface Props {
showTooltip: boolean;
position: XYPosition;
}
const props = defineProps<Props>();
const nodeCreatorStore = useNodeCreatorStore();
const containerCssVars = computed(() => ({
'--trigger-placeholder-left-position': `${props.position[0]}px`,
'--trigger-placeholder-top-position': `${props.position[1]}px`,
}));
</script>
<style lang="scss" module>

View file

@ -160,7 +160,7 @@ import { genericHelpers } from '@/mixins/genericHelpers';
import { mouseSelect } from '@/mixins/mouseSelect';
import { moveNodeWorkflow } from '@/mixins/moveNodeWorkflow';
import { restApi } from '@/mixins/restApi';
import { globalLinkActions } from '@/mixins/globalLinkActions';
import useGlobalLinkActions from '@/composables/useGlobalLinkActions';
import { showMessage } from '@/mixins/showMessage';
import { titleChange } from '@/mixins/titleChange';
import { newVersions } from '@/mixins/newVersions';
@ -256,7 +256,6 @@ export default mixins(
workflowHelpers,
workflowRun,
newVersions,
globalLinkActions,
debounceHelper,
)
.extend({
@ -271,6 +270,14 @@ export default mixins(
NodeCreation,
CanvasControls,
},
setup() {
const { registerCustomAction, unregisterCustomAction } = useGlobalLinkActions();
return {
registerCustomAction,
unregisterCustomAction,
};
},
errorCaptured: (err, vm, info) => {
console.error('errorCaptured'); // eslint-disable-line no-console
console.error(err); // eslint-disable-line no-console
@ -326,7 +333,6 @@ export default mixins(
},
nodeViewScale(newScale) {
const element = this.$refs.nodeView as HTMLDivElement;
if(element) {
element.style.transform = `scale(${newScale})`;
}
@ -397,8 +403,9 @@ export default mixins(
useSettingsStore,
useTemplatesStore,
useUIStore,
useUsersStore,
useWorkflowsStore,
useUsersStore,
useNodeCreatorStore,
useWorkflowsEEStore,
),
nativelyNumberSuffixedDefaults(): string[] {
@ -675,12 +682,10 @@ export default mixins(
},
showTriggerCreator(source: string) {
if(this.createNodeActive) return;
this.nodeCreatorStore.selectedType = TRIGGER_NODE_FILTER;
this.nodeCreatorStore.showScrim = true;
this.nodeCreatorStore.setSelectedType(TRIGGER_NODE_FILTER);
this.nodeCreatorStore.setShowScrim(true);
this.onToggleNodeCreator({ source, createNodeActive: true });
this.$nextTick(() => {
this.nodeCreatorStore.showTabs = false;
});
this.$nextTick(() => this.nodeCreatorStore.setShowTabs(false));
},
async openExecution(executionId: string) {
this.startLoading();
@ -1486,17 +1491,25 @@ export default mixins(
return;
}
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
if (nodeTypeName) {
const nodeTypeNames = event.dataTransfer.getData('nodeTypeName').split(',');
if (nodeTypeNames) {
const mousePosition = this.getMousePositionWithinNodeView(event);
this.addNode(nodeTypeName, {
position: [
mousePosition[0] - NodeViewUtils.NODE_SIZE / 2,
mousePosition[1] - NodeViewUtils.NODE_SIZE / 2,
],
dragAndDrop: true,
const nodesToAdd = nodeTypeNames.map((nodeTypeName: string, index: number) => {
return {
nodeTypeName,
position: [
// If adding more than one node, offset the X position
(mousePosition[0] - NodeViewUtils.NODE_SIZE / 2) + (NodeViewUtils.NODE_SIZE * (index * 2)),
mousePosition[1] - NodeViewUtils.NODE_SIZE / 2,
] as XYPosition,
dragAndDrop: true,
};
});
this.onAddNode(nodesToAdd, true);
this.createNodeActive = false;
}
},
@ -1608,7 +1621,7 @@ export default mixins(
return newNodeData;
},
async injectNode (nodeTypeName: string, options: AddNodeOptions = {}) {
async injectNode (nodeTypeName: string, options: AddNodeOptions = {}, showDetail = true) {
const nodeTypeData: INodeTypeDescription | null = this.nodeTypesStore.getNodeType(nodeTypeName);
if (nodeTypeData === null) {
@ -1665,7 +1678,6 @@ export default mixins(
}
// If a node is active then add the new node directly after the current one
// newNodeData.position = [activeNode.position[0], activeNode.position[1] + 60];
newNodeData.position = NodeViewUtils.getNewNodePosition(
this.nodes,
[lastSelectedNode.position[0] + NodeViewUtils.PUSH_NODES_OFFSET, lastSelectedNode.position[1] + yOffset],
@ -1718,9 +1730,12 @@ export default mixins(
// Automatically deselect all nodes and select the current one and also active
// current node
this.deselectAllNodes();
setTimeout(() => {
this.nodeSelectedByName(newNodeData.name, nodeTypeName !== STICKY_NODE_TYPE);
});
const preventDetailOpen = window.posthog?.getFeatureFlag && window.posthog?.getFeatureFlag('prevent-ndv-auto-open') === 'prevent';
if(showDetail && !preventDetailOpen) {
setTimeout(() => {
this.nodeSelectedByName(newNodeData.name, nodeTypeName !== STICKY_NODE_TYPE);
});
}
return newNodeData;
},
@ -1756,7 +1771,7 @@ export default mixins(
this.__addConnection(connectionData, true);
},
async addNode(nodeTypeName: string, options: AddNodeOptions = {}) {
async addNode(nodeTypeName: string, options: AddNodeOptions = {}, showDetail = true) {
if (!this.editAllowedCheck()) {
return;
}
@ -1765,7 +1780,7 @@ export default mixins(
const lastSelectedNode = this.lastSelectedNode;
const lastSelectedNodeOutputIndex = this.uiStore.lastSelectedNodeOutputIndex;
const newNodeData = await this.injectNode(nodeTypeName, options);
const newNodeData = await this.injectNode(nodeTypeName, options, showDetail);
if (!newNodeData) {
return;
}
@ -3234,16 +3249,39 @@ export default mixins(
if (createNodeActive === this.createNodeActive) return;
// Default to the trigger tab in node creator if there's no trigger node yet
if (!this.containsTrigger) {
this.nodeCreatorStore.selectedType = TRIGGER_NODE_FILTER;
}
if (!this.containsTrigger) this.nodeCreatorStore.setSelectedType(TRIGGER_NODE_FILTER);
this.createNodeActive = createNodeActive;
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source, createNodeActive });
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { source, createNodeActive, workflow_id: this.workflowsStore.workflowId });
const mode = this.nodeCreatorStore.selectedType === TRIGGER_NODE_FILTER ? 'trigger' : 'default';
this.$externalHooks().run('nodeView.createNodeActiveChanged', { source, mode, createNodeActive });
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { source, mode, createNodeActive, workflow_id: this.workflowsStore.workflowId });
},
onAddNode({ nodeTypeName, position }: { nodeTypeName: string; position?: [number, number] }) {
this.addNode(nodeTypeName, { position });
onAddNode(nodeTypes: Array<{ nodeTypeName: string; position: XYPosition }>, dragAndDrop: boolean) {
nodeTypes.forEach(({ nodeTypeName, position }, index) => {
this.addNode(nodeTypeName, { position, dragAndDrop }, nodeTypes.length === 1 || index > 0);
if(index === 0) return;
// If there's more than one node, we want to connect them
// this has to be done in mutation subscriber to make sure both nodes already
// exist
const actionWatcher = this.workflowsStore.$onAction(({ name, after, args }) => {
if(name === 'addNode' && args[0].type === nodeTypeName) {
after(() => {
const lastAddedNode = this.nodes[this.nodes.length - 1];
const previouslyAddedNode = this.nodes[this.nodes.length - 2];
this.$nextTick(() => this.connectTwoNodes(previouslyAddedNode.name, 0, lastAddedNode.name, 0));
// Position the added node to the right side of the previsouly added one
lastAddedNode.position = [
previouslyAddedNode.position[0] + (NodeViewUtils.NODE_SIZE * 2),
previouslyAddedNode.position[1],
];
actionWatcher();
});
}
});
});
},
async saveCurrentWorkflowExternal(callback: () => void) {
await this.saveCurrentWorkflow();

View file

@ -20,7 +20,7 @@ export class AffinityTrigger implements INodeType {
version: 1,
description: 'Handle Affinity events via webhooks',
defaults: {
name: 'Affinity-Trigger',
name: 'Affinity Trigger',
},
inputs: [],
outputs: ['main'],

View file

@ -41,7 +41,7 @@ export class AgileCrm implements INodeType {
version: 1,
description: 'Consume Agile CRM API',
defaults: {
name: 'AgileCRM',
name: 'Agile CRM',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -25,7 +25,7 @@ export class AsanaTrigger implements INodeType {
version: 1,
description: 'Starts the workflow when Asana events occur.',
defaults: {
name: 'Asana-Trigger',
name: 'Asana Trigger',
},
inputs: [],
outputs: ['main'],

View file

@ -25,7 +25,7 @@ export class AwsSnsTrigger implements INodeType {
version: 1,
description: 'Handle AWS SNS events via webhooks',
defaults: {
name: 'AWS-SNS-Trigger',
name: 'AWS SNS Trigger',
},
inputs: [],
outputs: ['main'],

View file

@ -34,7 +34,7 @@ export class CiscoWebex implements INodeType {
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume the Cisco Webex API',
defaults: {
name: 'Webex',
name: 'Webex by Cisco',
},
credentials: [
{

View file

@ -23,7 +23,7 @@ export class CiscoWebexTrigger implements INodeType {
subtitle: '={{$parameter["resource"] + ":" + $parameter["event"]}}',
description: 'Starts the workflow when Cisco Webex events occur.',
defaults: {
name: 'Webex Trigger',
name: 'Webex by Cisco Trigger',
},
inputs: [],
outputs: ['main'],

View file

@ -22,7 +22,7 @@ export class CustomerIo implements INodeType {
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Customer.io API',
defaults: {
name: 'CustomerIo',
name: 'Customer.io',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -39,7 +39,7 @@ const versionDescription: INodeTypeDescription = {
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 (IMAP)',
color: '#44AA22',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node

View file

@ -39,7 +39,7 @@ const versionDescription: INodeTypeDescription = {
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 (IMAP)',
color: '#44AA22',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node

View file

@ -11,7 +11,7 @@ export class ExecuteWorkflowTrigger implements INodeType {
description: 'Runs the flow when called by the Execute Workflow node from a different workflow',
maxNodes: 1,
defaults: {
name: 'When Called By Another Workflow',
name: 'Execute Workflow Trigger',
color: '#ff6d5a',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node

View file

@ -22,7 +22,7 @@ export class FunctionItem implements INodeType {
version: 1,
description: 'Run custom function code which gets executed once per item',
defaults: {
name: 'FunctionItem',
name: 'Function Item',
color: '#ddbb33',
},
inputs: ['main'],

View file

@ -20,7 +20,7 @@ export class Gitlab implements INodeType {
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Retrieve data from GitLab API',
defaults: {
name: 'Gitlab',
name: 'GitLab',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -87,7 +87,7 @@ export class GitlabTrigger implements INodeType {
'={{$parameter["owner"] + "/" + $parameter["repository"] + ": " + $parameter["events"].join(", ")}}',
description: 'Starts the workflow when GitLab events occur',
defaults: {
name: 'Gitlab Trigger',
name: 'GitLab Trigger',
},
inputs: [],
outputs: ['main'],

View file

@ -23,7 +23,7 @@ export class HubspotTrigger implements INodeType {
version: 1,
description: 'Starts the workflow when HubSpot events occur',
defaults: {
name: 'Hubspot Trigger',
name: 'HubSpot Trigger',
},
inputs: [],
outputs: ['main'],

View file

@ -46,7 +46,7 @@ export class Jira implements INodeType {
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Jira Software API',
defaults: {
name: 'Jira',
name: 'Jira Software',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -1,7 +1,6 @@
import { IExecuteFunctions } from 'n8n-core';
import {
IBinaryData,
IBinaryKeyData,
IDataObject,
INodeExecutionData,

View file

@ -11,7 +11,7 @@ export class ManualTrigger implements INodeType {
description: 'Runs the flow on clicking a button in n8n',
maxNodes: 1,
defaults: {
name: "On clicking 'execute'",
name: 'When clicking "Execute Workflow"',
color: '#909298',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node

View file

@ -20,7 +20,7 @@ export class Msg91 implements INodeType {
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Sends transactional SMS via MSG91',
defaults: {
name: 'Msg91',
name: 'MSG91',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -55,7 +55,7 @@ export class N8nTrainingCustomerDatastore implements INodeType {
subtitle: '={{$parameter["operation"]}}',
description: 'Dummy node used for n8n training',
defaults: {
name: 'Customer Datastore',
name: 'Customer Datastore (n8n training)',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -11,7 +11,7 @@ export class N8nTrainingCustomerMessenger implements INodeType {
version: 1,
description: 'Dummy node used for n8n training',
defaults: {
name: 'Customer Messenger',
name: 'Customer Messenger (n8n training)',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -10,7 +10,7 @@ export class NoOp implements INodeType {
version: 1,
description: 'No Operation',
defaults: {
name: 'NoOp',
name: 'No Operation, do nothing',
color: '#b0b0b0',
},
inputs: ['main'],

View file

@ -18,7 +18,7 @@ export class NotionTrigger implements INodeType {
description: 'Starts the workflow when Notion events occur',
subtitle: '={{$parameter["event"]}}',
defaults: {
name: 'Notion Trigger',
name: 'Notion Trigger (Beta)',
},
credentials: [
{

View file

@ -20,7 +20,7 @@ export const versionDescription: INodeTypeDescription = {
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Notion API (Beta)',
defaults: {
name: 'Notion',
name: 'Notion (Beta)',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -20,7 +20,7 @@ export const versionDescription: INodeTypeDescription = {
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Notion API (Beta)',
defaults: {
name: 'Notion',
name: 'Notion (Beta)',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -19,7 +19,7 @@ export class RssFeedRead implements INodeType {
version: 1,
description: 'Reads data from an RSS Feed',
defaults: {
name: 'RSS Feed Read',
name: 'RSS Read',
color: '#b02020',
},
inputs: ['main'],

View file

@ -18,7 +18,7 @@ export class SendInBlueTrigger implements INodeType {
],
displayName: 'SendInBlue Trigger',
defaults: {
name: 'SendInBlue-Trigger',
name: 'SendInBlue Trigger',
},
description: 'Starts the workflow when SendInBlue events occur',
group: ['trigger'],

View file

@ -10,7 +10,7 @@ export class SplitInBatches implements INodeType {
version: 1,
description: 'Split data into batches and iterate over each batch',
defaults: {
name: 'SplitInBatches',
name: 'Split In Batches',
color: '#007755',
},
inputs: ['main'],

View file

@ -10,7 +10,7 @@ export class StickyNote implements INodeType {
version: 1,
description: 'Make your workflow easier to understand',
defaults: {
name: 'Note',
name: 'Sticky Note',
color: '#FFD233',
},
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node

View file

@ -22,7 +22,7 @@ export class StopAndError implements INodeType {
version: 1,
description: 'Throw an error in the workflow',
defaults: {
name: 'Stop And Error',
name: 'Stop and Error',
color: '#ff0000',
},
inputs: ['main'],

View file

@ -21,7 +21,7 @@ export class TogglTrigger implements INodeType {
version: 1,
description: 'Starts the workflow when Toggl events occur',
defaults: {
name: 'Toggl',
name: 'Toggl Trigger',
},
credentials: [
{

View file

@ -16,9 +16,9 @@ export class VenafiTlsProtectDatacenter implements INodeType {
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Venafi TLS Protect Datacenter',
description: 'Consume Venafi TLS Protect Datacenter',
defaults: {
name: 'Venafi TLS Protect Datacenter',
name: 'Venafi TLS Protect Datacenter',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -34,9 +34,9 @@ export class VenafiTlsProtectCloud implements INodeType {
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Venafi TLS Protect Cloud API',
description: 'Consume Venafi TLS Protect Cloud API',
defaults: {
name: 'Venafi TLS Protect Cloud',
name: 'Venafi TLS Protect Cloud',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -19,7 +19,7 @@ export class VenafiTlsProtectCloudTrigger implements INodeType {
version: 1,
description: 'Starts the workflow when Venafi events occur',
defaults: {
name: 'Venafi TLS Protect Cloud Trigger',
name: 'Venafi TLS Protect Cloud Trigger',
},
credentials: [
{

View file

@ -13,7 +13,7 @@ export class WhatsApp implements INodeType {
subtitle: '={{ $parameter["resource"] + ": " + $parameter["operation"] }}',
description: 'Access WhatsApp API',
defaults: {
name: 'WhatsApp',
name: 'WhatsApp Business Cloud',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -76,7 +76,7 @@ export class ZohoCrm implements INodeType {
version: 1,
description: 'Consume Zoho CRM API',
defaults: {
name: 'Zoho',
name: 'Zoho CRM',
},
inputs: ['main'],
outputs: ['main'],

View file

@ -1369,6 +1369,12 @@ export interface IPostReceiveSort extends IPostReceiveBase {
};
}
export interface INodeActionTypeDescription extends INodeTypeDescription {
displayOptions?: IDisplayOptions;
values?: IDataObject;
actionKey: string;
}
export interface INodeTypeDescription extends INodeTypeBaseDescription {
version: number | number[];
defaults: INodeParameters;
@ -1407,6 +1413,7 @@ export interface INodeTypeDescription extends INodeTypeBaseDescription {
inactive: string;
};
};
actions?: INodeActionTypeDescription[];
}
export interface INodeHookDescription {

View file

@ -493,6 +493,7 @@ importers:
'@types/express': ^4.17.6
'@types/file-saver': ^2.0.1
'@types/jsonpath': ^0.2.0
'@types/lodash-es': ^4.17.6
'@types/lodash.camelcase': ^4.3.6
'@types/lodash.get': ^4.4.6
'@types/lodash.set': ^4.3.6
@ -607,6 +608,7 @@ importers:
'@types/express': 4.17.14
'@types/file-saver': 2.0.5
'@types/jsonpath': 0.2.0
'@types/lodash-es': 4.17.6
'@types/lodash.camelcase': 4.3.7
'@types/lodash.get': 4.4.7
'@types/lodash.set': 4.3.7
@ -5743,6 +5745,12 @@ packages:
resolution: {integrity: sha512-3YxO7RHRrmtYNX6Rhkr97bnXHrF1Ckfo4axENWLcBXWi+8B1WsNbqPqe5Eg6TA5survjAWWvLTu1KQesuLHVgQ==}
dev: true
/@types/lodash-es/4.17.6:
resolution: {integrity: sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg==}
dependencies:
'@types/lodash': 4.14.186
dev: true
/@types/lodash.camelcase/4.3.7:
resolution: {integrity: sha512-Nfi6jpo9vuEOSIJP+mpbTezKyEt75DQlbwjiDvs/JctWkbnHDoyQo5lWqdvgNiJmVUjcmkfvlrvSEgJYvurOKg==}
dependencies: