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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
353
packages/editor-ui/src/components/Node/NodeCreator/MainPanel.vue
Normal file
353
packages/editor-ui/src/components/Node/NodeCreator/MainPanel.vue
Normal 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>
|
||||
116
packages/editor-ui/src/components/Node/NodeCreator/NoResults.vue
Normal file
116
packages/editor-ui/src/components/Node/NodeCreator/NoResults.vue
Normal 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>
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
249
packages/editor-ui/src/components/Node/NodeCreator/NodeItem.vue
Normal file
249
packages/editor-ui/src/components/Node/NodeCreator/NodeItem.vue
Normal 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>
|
||||
130
packages/editor-ui/src/components/Node/NodeCreator/SearchBar.vue
Normal file
130
packages/editor-ui/src/components/Node/NodeCreator/SearchBar.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
169
packages/editor-ui/src/components/Node/NodeCreator/helpers.ts
Normal file
169
packages/editor-ui/src/components/Node/NodeCreator/helpers.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user