refactor(editor): encapsulate node creation actions (#4287)

* refactor(editor): encapsulate node creation actions

* fix(editor): add sticky node event name

* refactor(editor): move node creation and load it dynamically

* refactor(editor): move node creator

* refactor(editor): move node creator from node view to node creation

* fix(editor): fix node creator opening
This commit is contained in:
Csaba Tuncsik
2022-10-11 10:06:33 +02:00
committed by GitHub
parent 69684fc4f7
commit 0c78df61ea
15 changed files with 176 additions and 152 deletions

View File

@@ -0,0 +1,60 @@
<template>
<div :class="$style.category">
<span :class="$style.name">
{{ renderCategoryName(categoryName) }}
</span>
<font-awesome-icon
:class="$style.arrow"
icon="chevron-down"
v-if="item.properties.expanded"
/>
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import camelcase from 'lodash.camelcase';
import { CategoryName } from '@/plugins/i18n';
export default Vue.extend({
props: ['item'],
computed: {
categoryName() {
return camelcase(this.item.category);
},
},
methods: {
renderCategoryName(categoryName: CategoryName) {
const key = `nodeCreator.categoryNames.${categoryName}` as const;
return this.$locale.exists(key) ? this.$locale.baseText(key) : categoryName;
},
},
});
</script>
<style lang="scss" module>
.category {
font-size: 11px;
font-weight: bold;
letter-spacing: 1px;
line-height: 11px;
padding: 10px 0;
margin: 0 12px;
border-bottom: 1px solid $node-creator-border-color;
display: flex;
text-transform: uppercase;
}
.name {
flex-grow: 1;
}
.arrow {
font-size: 12px;
width: 12px;
color: $node-creator-arrow-color;
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div
:class="{
container: true,
clickable: clickable,
active: active,
}"
v-on="$listeners"
>
<CategoryItem
v-if="item.type === 'category'"
:item="item"
/>
<SubcategoryItem
v-else-if="item.type === 'subcategory'"
:item="item"
/>
<NodeItem
v-else-if="item.type === 'node'"
:nodeType="item.properties.nodeType"
:bordered="!lastNode"
@dragstart="$listeners.dragstart"
@dragend="$listeners.dragend"
/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import NodeItem from './NodeItem.vue';
import CategoryItem from './CategoryItem.vue';
import SubcategoryItem from './SubcategoryItem.vue';
export default Vue.extend({
name: 'CreatorItem',
components: {
CategoryItem,
SubcategoryItem,
NodeItem,
},
props: ['item', 'active', 'clickable', 'lastNode'],
});
</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

@@ -0,0 +1,100 @@
<template>
<div>
<div
:is="transitionsEnabled ? 'transition-group' : 'div'"
name="accordion"
@before-enter="beforeEnter"
@enter="enter"
@before-leave="beforeLeave"
@leave="leave"
>
<div
v-for="(item, index) in elements"
:key="item.key"
:class="item.type"
:data-key="item.key"
>
<CreatorItem
: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)"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { INodeCreateElement } from '@/Interface';
import Vue from 'vue';
import CreatorItem from './CreatorItem.vue';
export default Vue.extend({
name: 'ItemIterator',
components: {
CreatorItem,
},
props: ['elements', 'activeIndex', 'disabled', 'transitionsEnabled'],
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';
},
},
});
</script>
<style lang="scss" scoped>
.accordion-enter {
opacity: 0;
}
.accordion-leave-active {
opacity: 1;
}
.accordion-leave-active {
transition: all 0.25s ease, opacity 0.1s ease;
margin-top: 0;
}
.accordion-enter-active {
transition: all 0.25s ease, opacity 0.25s ease;
margin-top: 0;
}
.accordion-leave-to {
opacity: 0;
}
.accordion-enter-to {
opacity: 1;
}
.node + .category {
margin-top: 15px;
}
</style>

View File

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

View File

@@ -0,0 +1,116 @@
<template>
<div class="no-results">
<div class="icon">
<NoResultsIcon />
</div>
<div class="title">
<div>
{{ $locale.baseText('nodeCreator.noResults.weDidntMakeThatYet') }}
</div>
<div class="action">
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
<n8n-link @click="selectHttpRequest">{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}</n8n-link> {{ $locale.baseText('nodeCreator.noResults.or') }}
<n8n-link @click="selectWebhook">{{ $locale.baseText('nodeCreator.noResults.webhook') }}</n8n-link> {{ $locale.baseText('nodeCreator.noResults.node') }}
</div>
</div>
<div class="request">
<div>
{{ $locale.baseText('nodeCreator.noResults.wantUsToMakeItFaster') }}
</div>
<div>
<n8n-link
:to="REQUEST_NODE_FORM_URL"
>
<span>{{ $locale.baseText('nodeCreator.noResults.requestTheNode') }}</span>&nbsp;
<span>
<font-awesome-icon
class="external"
icon="external-link-alt"
:title="$locale.baseText('nodeCreator.noResults.requestTheNode')"
/>
</span>
</n8n-link>
</div>
</div>
</div>
</template>
<script lang="ts">
import { HTTP_REQUEST_NODE_TYPE, REQUEST_NODE_FORM_URL, WEBHOOK_NODE_TYPE } from '@/constants';
import Vue from 'vue';
import NoResultsIcon from './NoResultsIcon.vue';
export default Vue.extend({
name: 'NoResults',
components: {
NoResultsIcon,
},
data() {
return {
REQUEST_NODE_FORM_URL,
};
},
methods: {
selectWebhook() {
this.$emit('nodeTypeSelected', WEBHOOK_NODE_TYPE);
},
selectHttpRequest() {
this.$emit('nodeTypeSelected', HTTP_REQUEST_NODE_TYPE);
},
},
});
</script>
<style lang="scss" scoped>
.no-results {
background-color: $node-creator-no-results-background-color;
text-align: center;
height: 100%;
border-left: 1px solid $node-creator-border-color;
flex-direction: column;
font-weight: 400;
display: flex;
align-items: center;
align-content: center;
padding: 0 50px;
}
.title {
font-size: 22px;
line-height: 22px;
margin-top: 50px;
div {
margin-bottom: 15px;
}
}
.action, .request {
font-size: 14px;
line-height: 19px;
}
.request {
position: fixed;
bottom: 20px;
display: none;
@media (min-height: 550px) {
display: block;
}
}
.icon {
margin-top: 100px;
min-height: 67px;
opacity: .6;
}
.external {
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<svg width="75px" height="75px" viewBox="0 0 75 75" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>no-nodes-keyart</title>
<g id="Nodes-panel-prototype-V2.1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="nodes-panel-(component)" transform="translate(-2085.000000, -352.000000)">
<g id="nodes_panel" transform="translate(1880.000000, 151.000000)">
<g id="Panel" transform="translate(50.000000, 0.000000)">
<g id="Group-3" transform="translate(105.000000, 171.000000)">
<g id="no-nodes-keyart" transform="translate(50.000000, 30.000000)">
<rect id="Rectangle" x="0" y="0" width="75" height="75"></rect>
<g id="Group" transform="translate(6.562500, 8.164062)" fill="#C4C8D1" fill-rule="nonzero">
<polygon id="Rectangle" transform="translate(49.192016, 45.302553) rotate(-45.000000) translate(-49.192016, -45.302553) " points="44.5045606 32.0526802 53.8794707 32.0526802 53.8794707 58.5524261 44.5045606 58.5524261"></polygon>
<path d="M48.125,23.0859375 C54.15625,23.0859375 59.0625,18.1796875 59.0625,12.1484375 C59.0625,10.3359375 58.5625,8.6484375 57.78125,7.1484375 L49.34375,15.5859375 L44.6875,10.9296875 L53.125,2.4921875 C51.625,1.7109375 49.9375,1.2109375 48.125,1.2109375 C42.09375,1.2109375 37.1875,6.1171875 37.1875,12.1484375 C37.1875,13.4296875 37.4375,14.6484375 37.84375,15.7734375 L32.0625,21.5546875 L26.5,15.9921875 L28.71875,13.7734375 L24.3125,9.3671875 L30.9375,2.7421875 C27.28125,-0.9140625 21.34375,-0.9140625 17.6875,2.7421875 L6.625,13.8046875 L11.03125,18.2109375 L2.21875,18.2109375 L1.38777878e-15,20.4296875 L11.0625,31.4921875 L13.28125,29.2734375 L13.28125,20.4296875 L17.6875,24.8359375 L19.90625,22.6171875 L25.46875,28.1796875 L2.3125,51.3359375 L8.9375,57.9609375 L44.5,22.4296875 C45.625,22.8359375 46.84375,23.0859375 48.125,23.0859375 Z" id="Path"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>
</template>

View File

@@ -0,0 +1,132 @@
<template>
<SlideTransition>
<div
v-if="active"
class="node-creator"
ref="nodeCreator"
v-click-outside="onClickOutside"
@dragover="onDragOver"
@drop="onDrop"
>
<MainPanel
@nodeTypeSelected="nodeTypeSelected"
:categorizedItems="categorizedItems"
:categoriesWithNodes="categoriesWithNodes"
:searchItems="searchItems"
/>
</div>
</SlideTransition>
</template>
<script lang="ts">
import Vue from 'vue';
import { ICategoriesWithNodes, INodeCreateElement } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import SlideTransition from '../../transitions/SlideTransition.vue';
import MainPanel from './MainPanel.vue';
import { getCategoriesWithNodes, getCategorizedList } from './helpers';
import { mapGetters } from 'vuex';
export default Vue.extend({
name: 'NodeCreator',
components: {
MainPanel,
SlideTransition,
},
props: {
active: {
type: Boolean,
},
},
computed: {
...mapGetters('users', ['personalizedNodeTypes']),
allLatestNodeTypes(): INodeTypeDescription[] {
return this.$store.getters['nodeTypes/allLatestNodeTypes'];
},
visibleNodeTypes(): INodeTypeDescription[] {
return this.allLatestNodeTypes.filter((nodeType) => !nodeType.hidden);
},
categoriesWithNodes(): ICategoriesWithNodes {
return getCategoriesWithNodes(this.visibleNodeTypes, this.personalizedNodeTypes as string[]);
},
categorizedItems(): INodeCreateElement[] {
return getCategorizedList(this.categoriesWithNodes);
},
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;
});
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 nodeTypeName = event.dataTransfer.getData('nodeTypeName');
const nodeCreatorBoundingRect = (this.$refs.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();
}
},
},
});
</script>
<style scoped lang="scss">
::v-deep *, *:before, *:after {
box-sizing: border-box;
}
.node-creator {
position: fixed;
top: $header-height;
right: 0;
width: $node-creator-width;
height: 100%;
background-color: $node-creator-background-color;
z-index: 200;
color: $node-creator-text-color;
&:before {
box-sizing: border-box;
content: ' ';
border-left: 1px solid $node-creator-border-color;
width: 1px;
position: absolute;
height: 100%;
}
}
</style>

View File

@@ -0,0 +1,249 @@
<template>
<div
draggable
@dragstart="onDragStart"
@dragend="onDragEnd"
:class="{[$style['node-item']]: true, [$style.bordered]: bordered}"
>
<NodeIcon :class="$style['node-icon']" :nodeType="nodeType" />
<div>
<div :class="$style.details">
<span :class="$style.name">
{{ $locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: nodeType.displayName,
})
}}
</span>
<span v-if="isTrigger" :class="$style['trigger-icon']">
<TriggerIcon />
</span>
<n8n-tooltip v-if="isCommunityNode" placement="top">
<div
:class="$style['community-node-icon']"
slot="content"
v-html="$locale.baseText('generic.communityNode.tooltip', { interpolate: { packageName: nodeType.name.split('.')[0], docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL } })"
@click="onCommunityNodeTooltipClick"
>
</div>
<n8n-icon icon="cube" />
</n8n-tooltip>
</div>
<div :class="$style.description">
{{ $locale.headerText({
key: `headers.${shortNodeType}.description`,
fallback: nodeType.description,
})
}}
</div>
<div :class="$style['draggable-data-transfer']" ref="draggableDataTransfer" />
<transition name="node-item-transition">
<div
:class="$style.draggable"
:style="draggableStyle"
ref="draggable"
v-show="dragging"
>
<NodeIcon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" />
</div>
</transition>
</div>
</div>
</template>
<script lang="ts">
import {getNewNodePosition, NODE_SIZE} from '@/views/canvasHelpers';
import Vue from 'vue';
import NodeIcon from '../../NodeIcon.vue';
import TriggerIcon from '../../TriggerIcon.vue';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants';
import { isCommunityPackageName } from '../../helpers';
Vue.component('NodeIcon', NodeIcon);
Vue.component('TriggerIcon', TriggerIcon);
export default Vue.extend({
name: 'NodeItem',
props: [
'active',
'filter',
'nodeType',
'bordered',
],
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);
},
},
mounted() {
/**
* 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);
},
destroyed() {
document.body.removeEventListener("dragover", this.onDragOver);
},
methods: {
onDragStart(event: DragEvent): void {
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 {
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' });
}
},
},
});
</script>
<style lang="scss" module>
.node-item {
padding: 11px 8px 11px 0;
margin-left: 15px;
margin-right: 12px;
display: flex;
&.bordered {
border-bottom: 1px solid $node-creator-border-color;
}
}
.details {
align-items: center;
}
.node-icon {
min-width: 26px;
max-width: 26px;
margin-right: 15px;
}
.name {
font-weight: bold;
font-size: 14px;
line-height: 18px;
margin-right: 5px;
}
.packageName {
margin-right: 5px;
}
.description {
margin-top: 2px;
font-size: 11px;
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 {
vertical-align: top;
}
.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;
}
.draggable-data-transfer {
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

@@ -0,0 +1,130 @@
<template>
<div class="search-container">
<div :class="{ prefix: true, active: value.length > 0 }">
<font-awesome-icon icon="search" />
</div>
<div class="text">
<input
:placeholder="$locale.baseText('nodeCreator.searchBar.searchNodes')"
ref="input"
:value="value"
@input="onInput"
/>
</div>
<div class="suffix" v-if="value.length > 0" @click="clear">
<span class="clear el-icon-close clickable"></span>
</div>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/components/mixins/externalHooks';
export default mixins(externalHooks).extend({
name: "SearchBar",
props: ["value", "eventBus"],
mounted() {
if (this.$props.eventBus) {
this.$props.eventBus.$on("focus", () => {
this.focus();
});
}
setTimeout(() => {
this.focus();
}, 0);
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", "");
},
},
});
</script>
<style lang="scss" scoped>
.search-container {
display: flex;
height: 60px;
align-items: center;
padding-left: 14px;
padding-right: 20px;
border-bottom: 1px solid $node-creator-border-color;
background-color: $node-creator-search-background-color;
color: $node-creator-search-placeholder-color;
}
.prefix {
text-align: center;
font-size: 16px;
margin-right: 14px;
&.active {
color: $color-primary !important;
}
}
.text {
flex-grow: 1;
input {
width: 100%;
border: none !important;
outline: none;
font-size: 18px;
-webkit-appearance: none;
background-color: var(--color-background-xlight);
color: var(--color-text-dark);
&::placeholder,
&::-webkit-input-placeholder {
color: $node-creator-search-placeholder-color;
}
}
}
.suffix {
min-width: 20px;
text-align: center;
display: inline-block;
}
.clear {
background-color: $node-creator-search-clear-background-color;
border-radius: 50%;
height: 16px;
width: 16px;
font-size: 16px;
color: $node-creator-search-background-color;
display: inline-flex;
align-items: center;
&:hover {
background-color: $node-creator-search-clear-background-color-hover;
}
&:before {
line-height: 16px;
display: flex;
height: 16px;
width: 16px;
font-size: 15px;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<div :class="$style.subcategory">
<div :class="$style.details">
<div :class="$style.title">
{{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }}
</div>
<div v-if="item.properties.description" :class="$style.description">
{{ $locale.baseText(`nodeCreator.subcategoryDescriptions.${subcategoryName}`) }}
</div>
</div>
<div :class="$style.action">
<font-awesome-icon :class="$style.arrow" icon="arrow-right" />
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import camelcase from 'lodash.camelcase';
export default Vue.extend({
props: ['item'],
computed: {
subcategoryName() {
return camelcase(this.item.properties.subcategory);
},
},
});
</script>
<style lang="scss" module>
.subcategory {
display: flex;
padding: 11px 16px 11px 30px;
}
.details {
flex-grow: 1;
margin-right: 4px;
}
.title {
font-size: 14px;
font-weight: bold;
line-height: 16px;
margin-bottom: 3px;
}
.description {
font-size: 11px;
line-height: 16px;
font-weight: 400;
color: $node-creator-description-color;
}
.action {
display: flex;
align-items: center;
}
.arrow {
font-size: 12px;
width: 12px;
color: $node-creator-arrow-color;
}
</style>

View File

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

View File

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