mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-21 11:49:59 +00:00
feat(editor, core, cli): implement new workflow experience (#4358)
* feat(ExecuteWorkflowTrigger node): Implement ExecuteWorkflowTrigger node (#4108) * feat(ExecuteWorkflowTrigger node): Implement ExecuteWorkflowTrigger node * feat(editor): Do not show duplicate button if canvas contains `maxNodes` amount of nodes * feat(ManualTrigger node): Implement ManualTrigger node (#4110) * feat(ManualTrigger node): Implement ManualTrigger node * 📝 Remove generics doc items from ManualTrigger node * feat(editor-ui): Trigger tab redesign (#4150) * 🚧 Begin with TriggerPanel implementation, add Other Trigger Nodes subcategory * 🚧 Extracted categorized categories/subcategory/nodes rendering into its own component — CategorizedItems, removed SubcategoryPanel, added translations * ✨ Implement MainPanel background scrim * ♻️ Move `categoriesWithNodes`, 'visibleNodeTypes` and 'categorizedItems` to store, implemented dynamic categories count based on `selectedType` * 🐛 Fix SlideTransition for all the NodeCreato panels * 💄 Fix cursos for CategoryItem and NodeItem * 🐛 Make sure ALL_NODE_FILTER is always set when MainPanel is mounted * 🎨 Address PR comments * label: Use Array type for CategorizedItems props * 🏷️ Add proper types for Vue props * 🎨 Use standard component registration for CategorizedItems inside TriggerHelperPanel * 🎨 Use kebab case for main-panel and icon component * 🏷️ Improve types * feat(editor-ui): Redesign search input inside node creator panel (#4204) * 🚧 Begin with TriggerPanel implementation, add Other Trigger Nodes subcategory * 🚧 Extracted categorized categories/subcategory/nodes rendering into its own component — CategorizedItems, removed SubcategoryPanel, added translations * ✨ Implement MainPanel background scrim * ♻️ Move `categoriesWithNodes`, 'visibleNodeTypes` and 'categorizedItems` to store, implemented dynamic categories count based on `selectedType` * 🐛 Fix SlideTransition for all the NodeCreato panels * 💄 Fix cursos for CategoryItem and NodeItem * 🐛 Make sure ALL_NODE_FILTER is always set when MainPanel is mounted * 🎨 Address PR comments * label: Use Array type for CategorizedItems props * 🏷️ Add proper types for Vue props * 🎨 Use standard component registration for CategorizedItems inside TriggerHelperPanel * ✨ Redesign search input and unify usage of categorized items * 🏷️ Use lowercase "Boolean" as `isSearchVisible` computed return type * 🔥 Remove useless emit * ✨ Implement no result view based on subcategory, minor fixes * 🎨 Remove unused properties * feat(node-email): Change EmailReadImap display name and name (#4239) * feat(editor-ui): Implement "Choose a Triger" action and related behaviour (#4226) * ✨ Implement "Choose a Triger" action and related behaviour * 🔇 Lint fix * ♻️ Remove PlaceholderTrigger node, add a button instead * 🎨 Merge onMouseEnter and onMouseLeave to a single function * 💡 Add comment * 🔥 Remove PlaceholderNode registration * 🎨 Rename TriggerPlaceholderButton to CanvasAddButton * ✨ Add method to unregister custom action and rework CanvasAddButton centering logic * 🎨 Run `setRecenteredCanvasAddButtonPosition` on `CanvasAddButton` mount * fix(editor): Fix selecting of node from node-creator panel by clicking * 🔀 Merge fixes * fix(editor): Show execute workflow trigger instead of workflow trigger in the trigger helper panel * feat(editor): Fix node creator panel slide transition (#4261) * fix(editor): Fix node creator panel slide-in/slide-out transitions * 🎨 Fix naming * 🎨 Use kebab-case for transition component name * feat(editor): Disable execution and show notice when user tries to run workflow without enabled triggers * fix(editor): Address first batch of new WF experience review (#4279) * fix(editor): Fix first batch of review items * bug(editor): Fix nodeview canvas add button centering * 🔇 Fix linter errors * bug(ManualTrigger Node): Fix manual trigger node execution * fix(editor): Do not show canvas add button in execution or demo mode and prevent clicking if creator is open * fix(editor): do not show pin data tooltip for manual trigger node * fix(editor): do not use nodeViewOffset on zoomToFit * 💄 Add margin for last node creator item and set font-weight to 700 for category title * ✨ Position welcome note next to the added trigger node * 🐛 Remve always true welcome note * feat(editor): Minor UI and UX tweaks (#4328) * 💄 Make top viewport buttons less prominent * ✨ Allow user to switch to all tabs if it contains filter results, move nodecreator state props to its own module * 🔇 Fix linting errors * 🔇 Fix linting errors * 🔇 Fix linting errors * chore(build): Ping Turbo version to 1.5.5 * 💄 Minor traigger panel and node view style changes * 💬 Update display name of execute workflow trigger * feat(core, editor): Update subworkflow execution logic (#4269) * ✨ Implement `findWorkflowStart` * ⚡ Extend `WorkflowOperationError` * ⚡ Add `WorkflowOperationError` to toast * 📘 Extend interface * ✨ Add `subworkflowExecutionError` to store * ✨ Create `SubworkflowOperationError` * ⚡ Render subworkflow error as node error * 🚚 Move subworkflow start validation to `cli` * ⚡ Reset subworkflow execution error state * 🔥 Remove unused import * ⚡ Adjust CLI commands * 🔥 Remove unneeded check * 🔥 Remove stray log * ⚡ Simplify syntax * ⚡ Sort in case both Start and EWT present * ♻️ Address Omar's feedback * 🔥 Remove unneeded lint exception * ✏️ Fix copy * 👕 Fix lint * fix: moved find start node function to catchable place Co-authored-by: Omar Ajoue <krynble@gmail.com> * 💄 Change ExecuteWorkflow node to primary * ✨ Allow user to navigate to all tab if it contains search results * 🐛 Fixed canvas control button while in demo, disable workflow activation for non-activavle nodes and revert zoomToFit bottom offset * :fix: Do not chow request text if there's results * 💬 Update noResults text Co-authored-by: Iván Ovejero <ivov.src@gmail.com> Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
@@ -56,7 +56,7 @@ export default mixins(
|
||||
<style lang="scss">
|
||||
.main-header {
|
||||
background-color: var(--color-background-xlight);
|
||||
height: 65px;
|
||||
height: $header-height;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-bottom: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId"/>
|
||||
</span>
|
||||
<SaveButton
|
||||
type="secondary"
|
||||
:saved="!this.isDirty && !this.isNewWorkflow"
|
||||
:disabled="isWorkflowSaving"
|
||||
@click="onSaveButtonClick"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="node-wrapper" :style="nodePosition" :id="nodeId">
|
||||
<div class="select-background" v-show="isSelected"></div>
|
||||
<div :class="{'node-default': true, 'touch-active': isTouchActive, 'is-touch-device': isTouchDevice}" :data-name="data.name" :ref="data.name">
|
||||
<div :class="nodeClass" :style="nodeStyle" @dblclick="setNodeActive" @click.left="mouseLeftClick" v-touch:start="touchStart" v-touch:end="touchEnd">
|
||||
<div :class="nodeClass" :style="nodeStyle" @dblclick="setNodeActive" @click.left="mouseLeftClick" v-touch:start="touchStart" v-touch:end="touchEnd">
|
||||
<div v-if="!data.disabled" :class="{'node-info-icon': true, 'shift-icon': shiftOutputCount}">
|
||||
<div v-if="hasIssues" class="node-issues">
|
||||
<n8n-tooltip placement="bottom" >
|
||||
@@ -60,7 +60,7 @@
|
||||
<div v-touch:tap="disableNode" class="option" :title="$locale.baseText('node.activateDeactivateNode')">
|
||||
<font-awesome-icon :icon="nodeDisabledIcon" />
|
||||
</div>
|
||||
<div v-touch:tap="duplicateNode" class="option" :title="$locale.baseText('node.duplicateNode')">
|
||||
<div v-touch:tap="duplicateNode" class="option" :title="$locale.baseText('node.duplicateNode')" v-if="isDuplicatable">
|
||||
<font-awesome-icon icon="clone" />
|
||||
</div>
|
||||
<div v-touch:tap="setNodeActive" class="option touch" :title="$locale.baseText('node.editNode')" v-if="!isReadOnly">
|
||||
@@ -91,7 +91,7 @@
|
||||
<script lang="ts">
|
||||
|
||||
import Vue from 'vue';
|
||||
import {CUSTOM_API_CALL_KEY, LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, WAIT_TIME_UNLIMITED} from '@/constants';
|
||||
import { CUSTOM_API_CALL_KEY, LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, WAIT_TIME_UNLIMITED, MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
import { nodeBase } from '@/components/mixins/nodeBase';
|
||||
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
|
||||
@@ -126,6 +126,10 @@ export default mixins(
|
||||
NodeIcon,
|
||||
},
|
||||
computed: {
|
||||
isDuplicatable(): boolean {
|
||||
if(!this.nodeType) return true;
|
||||
return this.nodeType.maxNodes === undefined || this.sameTypeNodes.length < this.nodeType.maxNodes;
|
||||
},
|
||||
isScheduledGroup (): boolean {
|
||||
return this.nodeType?.group.includes('schedule') === true;
|
||||
},
|
||||
@@ -183,8 +187,11 @@ export default mixins(
|
||||
|
||||
return nodes.length === 1;
|
||||
},
|
||||
isManualTypeNode (): boolean {
|
||||
return this.data.type === MANUAL_TRIGGER_NODE_TYPE;
|
||||
},
|
||||
isTriggerNode (): boolean {
|
||||
return !!(this.nodeType && this.nodeType.group.includes('trigger'));
|
||||
return this.$store.getters['nodeTypes/isTriggerNode'](this.data.type);
|
||||
},
|
||||
isTriggerNodeTooltipEmpty () : boolean {
|
||||
return this.nodeType !== null ? this.nodeType.eventTriggerDescription === '' : false;
|
||||
@@ -198,6 +205,9 @@ export default mixins(
|
||||
node (): INodeUi | undefined { // same as this.data but reactive..
|
||||
return this.$store.getters.nodesByName[this.name] as INodeUi | undefined;
|
||||
},
|
||||
sameTypeNodes (): INodeUi[] {
|
||||
return this.$store.getters.allNodes.filter((node: INodeUi) => node.type === this.data.type);
|
||||
},
|
||||
nodeClass (): object {
|
||||
return {
|
||||
'node-box': true,
|
||||
@@ -378,7 +388,7 @@ export default mixins(
|
||||
},
|
||||
methods: {
|
||||
showPinDataDiscoveryTooltip(dataItemsCount: number): void {
|
||||
if (!this.isTriggerNode || this.isScheduledGroup || dataItemsCount === 0) return;
|
||||
if (!this.isTriggerNode || this.isManualTypeNode || this.isScheduledGroup || dataItemsCount === 0) return;
|
||||
|
||||
localStorage.setItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, 'true');
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
<div>
|
||||
<div v-if="!createNodeActive" :class="[$style.nodeButtonsWrapper, showStickyButton ? $style.noEvents : '']" @mouseenter="onCreateMenuHoverIn">
|
||||
<div :class="$style.nodeCreatorButton">
|
||||
<n8n-icon-button size="xlarge" icon="plus" @click="openNodeCreator" :title="$locale.baseText('nodeView.addNode')"/>
|
||||
<n8n-icon-button size="xlarge" icon="plus" type="tertiary" :class="$style.nodeCreatorPlus" @click="openNodeCreator" :title="$locale.baseText('nodeView.addNode')"/>
|
||||
<div :class="[$style.addStickyButton, showStickyButton ? $style.visibleButton : '']" @click="addStickyNote">
|
||||
<n8n-icon-button size="medium" type="secondary" :icon="['far', 'note-sticky']" :title="$locale.baseText('nodeView.addSticky')"/>
|
||||
<n8n-icon-button size="medium" type="tertiary" :icon="['far', 'note-sticky']" :title="$locale.baseText('nodeView.addSticky')"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<node-creator :active="createNodeActive" @nodeTypeSelected="nodeTypeSelected" @closeNodeCreator="closeNodeCreator" />
|
||||
<node-creator
|
||||
:active="createNodeActive"
|
||||
@nodeTypeSelected="nodeTypeSelected"
|
||||
@closeNodeCreator="closeNodeCreator"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -120,12 +124,25 @@ export default Vue.extend({
|
||||
.nodeCreatorButton {
|
||||
position: fixed;
|
||||
text-align: center;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
top: calc(#{$header-height} + var(--spacing-s));
|
||||
right: var(--spacing-s);
|
||||
pointer-events: all !important;
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
border-color: var(--color-foreground-xdark);
|
||||
color: var(--color-foreground-xdark);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: var(--color-background-xlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
.nodeCreatorPlus {
|
||||
border-width: 2px;
|
||||
border-radius: var(--border-radius-base);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
<template>
|
||||
<transition :name="activeSubcategoryTitle ? 'panel-slide-in' : 'panel-slide-out'" >
|
||||
<div
|
||||
:class="$style.categorizedItems"
|
||||
ref="mainPanelContainer"
|
||||
@click="onClickInside"
|
||||
tabindex="0"
|
||||
@keydown.capture="nodeFilterKeyDown"
|
||||
:key="`${activeSubcategoryTitle}_transition`"
|
||||
>
|
||||
<div class="header" v-if="$slots.header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<div :class="$style.subcategoryHeader" v-if="activeSubcategory">
|
||||
<button :class="$style.subcategoryBackButton" @click="onSubcategoryClose">
|
||||
<font-awesome-icon :class="$style.subcategoryBackIcon" icon="arrow-left" size="2x" />
|
||||
</button>
|
||||
<span v-text="activeSubcategoryTitle" />
|
||||
</div>
|
||||
|
||||
<search-bar
|
||||
v-if="isSearchVisible"
|
||||
:value="nodeFilter"
|
||||
@input="onNodeFilterChange"
|
||||
:eventBus="searchEventBus"
|
||||
/>
|
||||
<div v-if="searchFilter.length === 0" :class="$style.scrollable">
|
||||
<item-iterator
|
||||
:elements="renderedItems"
|
||||
:activeIndex="activeSubcategory ? activeSubcategoryIndex : activeIndex"
|
||||
:transitionsEnabled="true"
|
||||
@selected="selected"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:class="$style.scrollable"
|
||||
v-else-if="filteredNodeTypes.length > 0"
|
||||
>
|
||||
<item-iterator
|
||||
:elements="filteredNodeTypes"
|
||||
:activeIndex="activeSubcategory ? activeSubcategoryIndex : activeIndex"
|
||||
@selected="selected"
|
||||
/>
|
||||
</div>
|
||||
<no-results v-else :showRequest="filteredAllNodeTypes.length === 0" :show-icon="filteredAllNodeTypes.length === 0">
|
||||
<!-- There are results in other sub-categories/tabs -->
|
||||
<template v-if="filteredAllNodeTypes.length > 0">
|
||||
<p
|
||||
v-html="$locale.baseText('nodeCreator.noResults.clickToSeeResults')"
|
||||
slot="title"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Regular Search -->
|
||||
<template v-else>
|
||||
<p v-text="$locale.baseText('nodeCreator.noResults.weDidntMakeThatYet')" slot="title" />
|
||||
<template slot="action">
|
||||
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
|
||||
<n8n-link @click="selectHttpRequest" v-if="[REGULAR_NODE_FILTER, ALL_NODE_FILTER].includes(selectedType)">
|
||||
{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}
|
||||
</n8n-link>
|
||||
<template v-if="selectedType === ALL_NODE_FILTER">
|
||||
{{ $locale.baseText('nodeCreator.noResults.or') }}
|
||||
</template>
|
||||
|
||||
<n8n-link @click="selectWebhook" v-if="[TRIGGER_NODE_FILTER, ALL_NODE_FILTER].includes(selectedType)">
|
||||
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
|
||||
</n8n-link>
|
||||
{{ $locale.baseText('nodeCreator.noResults.node') }}
|
||||
</template>
|
||||
</template>
|
||||
</no-results>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropType } from 'vue';
|
||||
import camelcase from 'lodash.camelcase';
|
||||
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
import { globalLinkActions } from '@/components/mixins/globalLinkActions';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import ItemIterator from './ItemIterator.vue';
|
||||
import NoResults from './NoResults.vue';
|
||||
import SearchBar from './SearchBar.vue';
|
||||
import { INodeCreateElement, INodeItemProps, ISubcategoryItemProps, ICategoriesWithNodes, ICategoryItemProps, INodeFilterType } from '@/Interface';
|
||||
import { CORE_NODES_CATEGORY, WEBHOOK_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, ALL_NODE_FILTER, TRIGGER_NODE_FILTER, REGULAR_NODE_FILTER, NODE_TYPE_COUNT_MAPPER } from '@/constants';
|
||||
import { matchesNodeType, matchesSelectType } from './helpers';
|
||||
import { BaseTextKey } from '@/plugins/i18n';
|
||||
|
||||
export default mixins(externalHooks, globalLinkActions).extend({
|
||||
name: 'CategorizedItems',
|
||||
components: {
|
||||
ItemIterator,
|
||||
NoResults,
|
||||
SearchBar,
|
||||
},
|
||||
props: {
|
||||
searchItems: {
|
||||
type: Array as PropType<INodeCreateElement[]>,
|
||||
},
|
||||
excludedCategories: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
excludedSubcategories: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
firstLevelItems: {
|
||||
type: Array as PropType<INodeCreateElement[]>,
|
||||
default: () => [],
|
||||
},
|
||||
initialActiveCategories: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
initialActiveIndex: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeCategory: this.initialActiveCategories || [] as string[],
|
||||
// Keep track of activated subcategories so we could traverse back more than one level
|
||||
activeSubcategoryHistory: [] as INodeCreateElement[],
|
||||
activeIndex: this.initialActiveIndex,
|
||||
activeSubcategoryIndex: 0,
|
||||
searchEventBus: new Vue(),
|
||||
ALL_NODE_FILTER,
|
||||
TRIGGER_NODE_FILTER,
|
||||
REGULAR_NODE_FILTER,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.registerCustomAction('showAllNodeCreatorNodes', this.switchToAllTabAndFilter);
|
||||
},
|
||||
destroyed() {
|
||||
this.$store.commit('nodeCreator/setFilter', '');
|
||||
this.unregisterCustomAction('showAllNodeCreatorNodes');
|
||||
},
|
||||
computed: {
|
||||
activeSubcategory(): INodeCreateElement | null {
|
||||
return this.activeSubcategoryHistory[this.activeSubcategoryHistory.length - 1] || null;
|
||||
},
|
||||
nodeFilter(): string {
|
||||
return this.$store.getters['nodeCreator/itemsFilter'];
|
||||
},
|
||||
selectedType(): INodeFilterType {
|
||||
return this.$store.getters['nodeCreator/selectedType'];
|
||||
},
|
||||
categoriesWithNodes(): ICategoriesWithNodes {
|
||||
return this.$store.getters['nodeTypes/categoriesWithNodes'];
|
||||
},
|
||||
categorizedItems(): INodeCreateElement[] {
|
||||
return this.$store.getters['nodeTypes/categorizedItems'];
|
||||
},
|
||||
activeSubcategoryTitle(): string {
|
||||
if(!this.activeSubcategory || !this.activeSubcategory.properties) return '';
|
||||
const subcategoryName = camelcase((this.activeSubcategory.properties as ISubcategoryItemProps).subcategory);
|
||||
const titleLocaleKey = `nodeCreator.subcategoryTitles.${subcategoryName}` as BaseTextKey;
|
||||
const nameLocaleKey = `nodeCreator.subcategoryNames.${subcategoryName}` as BaseTextKey;
|
||||
|
||||
const titleLocale = this.$locale.baseText(titleLocaleKey);
|
||||
const nameLocale = this.$locale.baseText(nameLocaleKey);
|
||||
|
||||
// If resolved title locale is same as the locale key it means it doesn't exist
|
||||
// so we fallback to the subcategoryName
|
||||
return titleLocale === titleLocaleKey ? nameLocale : titleLocale;
|
||||
},
|
||||
searchFilter(): string {
|
||||
return this.nodeFilter.toLowerCase().trim();
|
||||
},
|
||||
filteredNodeTypes(): INodeCreateElement[] {
|
||||
const searchableNodes = this.subcategorizedNodes.length > 0 ? this.subcategorizedNodes : this.searchItems;
|
||||
const filter = this.searchFilter;
|
||||
const matchedCategorizedNodes = searchableNodes.filter((el: INodeCreateElement) => {
|
||||
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', {
|
||||
nodeFilter: this.nodeFilter,
|
||||
result: matchedCategorizedNodes,
|
||||
selectedType: this.selectedType,
|
||||
});
|
||||
}, 0);
|
||||
|
||||
return matchedCategorizedNodes;
|
||||
},
|
||||
filteredAllNodeTypes(): INodeCreateElement[] {
|
||||
if(this.filteredNodeTypes.length > 0) return [];
|
||||
|
||||
const matchedAllNodex = this.searchItems.filter((el: INodeCreateElement) => {
|
||||
return this.searchFilter && matchesNodeType(el, this.searchFilter);
|
||||
});
|
||||
|
||||
return matchedAllNodex;
|
||||
},
|
||||
categorized(): INodeCreateElement[] {
|
||||
return this.categorizedItems && this.categorizedItems
|
||||
.reduce((accu: INodeCreateElement[], el: INodeCreateElement) => {
|
||||
if((this.excludedCategories || []).includes(el.category)) return accu;
|
||||
|
||||
if(
|
||||
el.type === 'subcategory' &&
|
||||
(this.excludedSubcategories || []).includes((el.properties as ISubcategoryItemProps).subcategory)
|
||||
) {
|
||||
return accu;
|
||||
}
|
||||
|
||||
if (
|
||||
el.type !== 'category' &&
|
||||
!this.activeCategory.includes(el.category)
|
||||
) {
|
||||
return accu;
|
||||
}
|
||||
|
||||
if (!matchesSelectType(el, this.selectedType)) {
|
||||
return accu;
|
||||
}
|
||||
|
||||
if (el.type === 'category') {
|
||||
accu.push({
|
||||
...el,
|
||||
properties: {
|
||||
expanded: this.activeCategory.includes(el.category),
|
||||
},
|
||||
} as INodeCreateElement);
|
||||
return accu;
|
||||
}
|
||||
|
||||
accu.push(el);
|
||||
return accu;
|
||||
}, []);
|
||||
},
|
||||
|
||||
subcategorizedItems(): INodeCreateElement[] {
|
||||
const activeSubcategory = this.activeSubcategory;
|
||||
if(!activeSubcategory) return [];
|
||||
|
||||
const category = activeSubcategory.category;
|
||||
const subcategory = (activeSubcategory.properties as ISubcategoryItemProps).subcategory;
|
||||
|
||||
// If no category is set, we use all categorized nodes
|
||||
const nodes = category
|
||||
? this.categoriesWithNodes[category][subcategory].nodes
|
||||
: this.categorized;
|
||||
|
||||
return nodes.filter((el: INodeCreateElement) => matchesSelectType(el, this.selectedType));
|
||||
},
|
||||
|
||||
subcategorizedNodes(): INodeCreateElement[] {
|
||||
return this.subcategorizedItems.filter(node => node.type === 'node');
|
||||
},
|
||||
|
||||
renderedItems(): INodeCreateElement[] {
|
||||
if(this.firstLevelItems.length > 0 && this.activeSubcategory === null) return this.firstLevelItems;
|
||||
if(this.subcategorizedItems.length === 0) return this.categorized;
|
||||
|
||||
return this.subcategorizedItems;
|
||||
},
|
||||
|
||||
isSearchVisible(): boolean {
|
||||
if(this.subcategorizedItems.length === 0) return true;
|
||||
|
||||
let totalItems = 0;
|
||||
for (const item of this.subcategorizedItems) {
|
||||
// Category contains many nodes so we need to count all of them
|
||||
// for the current selectedType
|
||||
if(item.type === 'category') {
|
||||
const categoryItems = this.categoriesWithNodes[item.key];
|
||||
const categoryItemsCount = Object.values(categoryItems)?.[0];
|
||||
const countKeys = NODE_TYPE_COUNT_MAPPER[this.selectedType];
|
||||
|
||||
for (const countKey of countKeys) {
|
||||
totalItems += categoryItemsCount[(countKey as "triggerCount" | "regularCount")];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
// If it's not category, it must be just a node item so we count it as 1
|
||||
totalItems += 1;
|
||||
}
|
||||
|
||||
return totalItems > 9;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isSearchVisible(isVisible) {
|
||||
if(isVisible === false) {
|
||||
// Focus the root container when search is hidden to make sure
|
||||
// keyboard navigation still works
|
||||
this.$nextTick(() => {
|
||||
(this.$refs.mainPanelContainer as HTMLElement).focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
nodeFilter(newValue, oldValue) {
|
||||
// Reset the index whenver the filter-value changes
|
||||
this.activeIndex = 0;
|
||||
this.activeSubcategoryIndex = 0;
|
||||
this.$externalHooks().run('nodeCreateList.nodeFilterChanged', {
|
||||
oldValue,
|
||||
newValue,
|
||||
selectedType: this.selectedType,
|
||||
filteredNodes: this.filteredNodeTypes,
|
||||
});
|
||||
this.$telemetry.trackNodesPanel('nodeCreateList.nodeFilterChanged', {
|
||||
oldValue,
|
||||
newValue,
|
||||
selectedType: this.selectedType,
|
||||
filteredNodes: this.filteredNodeTypes,
|
||||
workflow_id: this.$store.getters.workflowId,
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
switchToAllTabAndFilter() {
|
||||
const currentFilter = this.nodeFilter;
|
||||
this.$store.commit('nodeCreator/setShowTabs', true);
|
||||
this.$store.commit('nodeCreator/setSelectedType', ALL_NODE_FILTER);
|
||||
this.activeSubcategoryHistory = [];
|
||||
|
||||
this.$nextTick(() => this.$store.commit('nodeCreator/setFilter', currentFilter));
|
||||
},
|
||||
onNodeFilterChange(filter: string) {
|
||||
this.$store.commit('nodeCreator/setFilter', filter);
|
||||
},
|
||||
selectWebhook() {
|
||||
this.$emit('nodeTypeSelected', WEBHOOK_NODE_TYPE);
|
||||
},
|
||||
selectHttpRequest() {
|
||||
this.$emit('nodeTypeSelected', HTTP_REQUEST_NODE_TYPE);
|
||||
},
|
||||
nodeFilterKeyDown(e: KeyboardEvent) {
|
||||
// We only want to propagate 'Escape' as it closes the node-creator and
|
||||
// 'Tab' which toggles it
|
||||
if (!['Escape', 'Tab'].includes(e.key)) e.stopPropagation();
|
||||
|
||||
// Prevent cursors position change
|
||||
if(['ArrowUp', 'ArrowDown'].includes(e.key)) e.preventDefault();
|
||||
|
||||
if (this.activeSubcategory) {
|
||||
const activeList = this.subcategorizedItems;
|
||||
const activeNodeType = activeList[this.activeSubcategoryIndex];
|
||||
|
||||
if (e.key === 'ArrowDown' && this.activeSubcategory) {
|
||||
this.activeSubcategoryIndex++;
|
||||
this.activeSubcategoryIndex = Math.min(
|
||||
this.activeSubcategoryIndex,
|
||||
activeList.length - 1,
|
||||
);
|
||||
}
|
||||
else if (e.key === 'ArrowUp' && this.activeSubcategory) {
|
||||
this.activeSubcategoryIndex--;
|
||||
this.activeSubcategoryIndex = Math.max(this.activeSubcategoryIndex, 0);
|
||||
}
|
||||
else if (e.key === 'Enter') {
|
||||
this.selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowLeft' && activeNodeType?.type === 'category' && (activeNodeType.properties as ICategoryItemProps).expanded) {
|
||||
this.selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
this.onSubcategoryClose();
|
||||
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'category' && !(activeNodeType.properties as ICategoryItemProps).expanded) {
|
||||
this.selected(activeNodeType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const activeList = this.searchFilter.length > 0 ? this.filteredNodeTypes : this.renderedItems;
|
||||
const activeNodeType = activeList[this.activeIndex];
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
this.activeIndex++;
|
||||
// Make sure that we stop at the last nodeType
|
||||
this.activeIndex = Math.min(
|
||||
this.activeIndex,
|
||||
activeList.length - 1,
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
this.activeIndex--;
|
||||
// Make sure that we do not get before the first nodeType
|
||||
this.activeIndex = Math.max(this.activeIndex, 0);
|
||||
} else if (e.key === 'Enter' && activeNodeType) {
|
||||
this.selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'subcategory') {
|
||||
this.selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowRight' && activeNodeType?.type === 'category' && !(activeNodeType.properties as ICategoryItemProps).expanded) {
|
||||
this.selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowLeft' && activeNodeType?.type === 'category' && (activeNodeType.properties as ICategoryItemProps).expanded) {
|
||||
this.selected(activeNodeType);
|
||||
}
|
||||
},
|
||||
selected(element: INodeCreateElement) {
|
||||
const typeHandler = {
|
||||
node: () => this.$emit('nodeTypeSelected', (element.properties as INodeItemProps).nodeType.name),
|
||||
category: () => this.onCategorySelected(element.category),
|
||||
subcategory: () => this.onSubcategorySelected(element),
|
||||
};
|
||||
|
||||
typeHandler[element.type]();
|
||||
},
|
||||
onCategorySelected(category: string) {
|
||||
if (this.activeCategory.includes(category)) {
|
||||
this.activeCategory = this.activeCategory.filter(
|
||||
(active: string) => active !== category,
|
||||
);
|
||||
} else {
|
||||
this.activeCategory = [...this.activeCategory, category];
|
||||
this.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', { category_name: category, workflow_id: this.$store.getters.workflowId });
|
||||
}
|
||||
|
||||
this.activeIndex = this.categorized.findIndex(
|
||||
(el: INodeCreateElement) => el.category === category,
|
||||
);
|
||||
},
|
||||
onSubcategorySelected(selected: INodeCreateElement) {
|
||||
this.$emit('onSubcategorySelected', selected);
|
||||
this.$store.commit('nodeCreator/setShowTabs', false);
|
||||
this.activeSubcategoryIndex = 0;
|
||||
this.activeSubcategoryHistory.push(selected);
|
||||
this.$telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', { selected, workflow_id: this.$store.getters.workflowId });
|
||||
},
|
||||
|
||||
onSubcategoryClose() {
|
||||
this.$emit('subcategoryClose', this.activeSubcategory);
|
||||
this.activeSubcategoryHistory.pop();
|
||||
this.activeSubcategoryIndex = 0;
|
||||
this.$store.commit('nodeCreator/setFilter', '');
|
||||
|
||||
if(!this.$store.getters['nodeCreator/showScrim']) {
|
||||
this.$store.commit('nodeCreator/setShowTabs', true);
|
||||
}
|
||||
},
|
||||
|
||||
onClickInside() {
|
||||
this.searchEventBus.$emit('focus');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
:global(.panel-slide-in-leave-active),
|
||||
:global(.panel-slide-in-enter-active),
|
||||
:global(.panel-slide-out-leave-active),
|
||||
:global(.panel-slide-out-enter-active) {
|
||||
transition: transform 300ms ease;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
|
||||
:global(.panel-slide-out-enter),
|
||||
:global(.panel-slide-in-leave-to) {
|
||||
transform: translateX(0);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
:global(.panel-slide-out-leave-to),
|
||||
:global(.panel-slide-in-enter) {
|
||||
transform: translateX(100%);
|
||||
// Make sure the leaving panel stays on top
|
||||
// for the slide-out panel effect
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.categorizedItems {
|
||||
background: white;
|
||||
height: 100%;
|
||||
|
||||
background-color: $node-creator-background-color;
|
||||
&:before {
|
||||
box-sizing: border-box;
|
||||
content: '';
|
||||
border-left: 1px solid $node-creator-border-color;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.subcategoryHeader {
|
||||
border-bottom: $node-creator-border-color solid 1px;
|
||||
height: 50px;
|
||||
background-color: $node-creator-subcategory-panel-header-bacground-color;
|
||||
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 11px 15px;
|
||||
}
|
||||
|
||||
.subcategoryBackButton {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-s) 0;
|
||||
}
|
||||
|
||||
.subcategoryBackIcon {
|
||||
color: $node-creator-arrow-color;
|
||||
height: 16px;
|
||||
margin-right: var(--spacing-s);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
height: calc(100% - 120px);
|
||||
padding-top: 1px;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div :class="$style.category">
|
||||
<span :class="$style.name">
|
||||
{{ renderCategoryName(categoryName) }}
|
||||
{{ renderCategoryName(categoryName) }} ({{ nodesCount }})
|
||||
</span>
|
||||
<font-awesome-icon
|
||||
:class="$style.arrow"
|
||||
@@ -13,16 +13,49 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Vue, { PropType } from 'vue';
|
||||
import camelcase from 'lodash.camelcase';
|
||||
import { CategoryName } from '@/plugins/i18n';
|
||||
import { INodeCreateElement, ICategoriesWithNodes } from '@/Interface';
|
||||
import { NODE_TYPE_COUNT_MAPPER } from '@/constants';
|
||||
|
||||
|
||||
export default Vue.extend({
|
||||
props: ['item'],
|
||||
props: {
|
||||
item: {
|
||||
type: Object as PropType<INodeCreateElement>,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
selectedType(): "Regular" | "Trigger" | "All" {
|
||||
return this.$store.getters['nodeCreator/selectedType'];
|
||||
},
|
||||
categoriesWithNodes(): ICategoriesWithNodes {
|
||||
return this.$store.getters['nodeTypes/categoriesWithNodes'];
|
||||
},
|
||||
categorizedItems(): INodeCreateElement[] {
|
||||
return this.$store.getters['nodeTypes/categorizedItems'];
|
||||
},
|
||||
categoryName() {
|
||||
return camelcase(this.item.category);
|
||||
},
|
||||
nodesCount(): number {
|
||||
const currentCategory = this.categoriesWithNodes[this.item.category];
|
||||
const subcategories = Object.keys(currentCategory);
|
||||
|
||||
// We need to sum subcategories count for the curent nodeType view
|
||||
// to get the total count of category
|
||||
const count = subcategories.reduce((accu: number, subcategory: string) => {
|
||||
const countKeys = NODE_TYPE_COUNT_MAPPER[this.selectedType];
|
||||
|
||||
for (const countKey of countKeys) {
|
||||
accu += currentCategory[subcategory][(countKey as "triggerCount" | "regularCount")];
|
||||
}
|
||||
|
||||
return accu;
|
||||
}, 0);
|
||||
return count;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
renderCategoryName(categoryName: CategoryName) {
|
||||
@@ -38,7 +71,7 @@ export default Vue.extend({
|
||||
<style lang="scss" module>
|
||||
.category {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
line-height: 11px;
|
||||
padding: 10px 0;
|
||||
@@ -46,6 +79,7 @@ export default Vue.extend({
|
||||
border-bottom: 1px solid $node-creator-border-color;
|
||||
display: flex;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name {
|
||||
|
||||
@@ -7,20 +7,19 @@
|
||||
}"
|
||||
v-on="$listeners"
|
||||
>
|
||||
<CategoryItem
|
||||
<category-item
|
||||
v-if="item.type === 'category'"
|
||||
:item="item"
|
||||
/>
|
||||
|
||||
<SubcategoryItem
|
||||
<subcategory-item
|
||||
v-else-if="item.type === 'subcategory'"
|
||||
:item="item"
|
||||
/>
|
||||
|
||||
<NodeItem
|
||||
<node-item
|
||||
v-else-if="item.type === 'node'"
|
||||
:nodeType="item.properties.nodeType"
|
||||
:bordered="!lastNode"
|
||||
@dragstart="$listeners.dragstart"
|
||||
@dragend="$listeners.dragend"
|
||||
/>
|
||||
@@ -28,10 +27,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Vue, { PropType } from 'vue';
|
||||
import { INodeCreateElement } from '@/Interface';
|
||||
import NodeItem from './NodeItem.vue';
|
||||
import CategoryItem from './CategoryItem.vue';
|
||||
import SubcategoryItem from './SubcategoryItem.vue';
|
||||
import CategoryItem from './CategoryItem.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'CreatorItem',
|
||||
@@ -40,7 +40,20 @@ export default Vue.extend({
|
||||
SubcategoryItem,
|
||||
NodeItem,
|
||||
},
|
||||
props: ['item', 'active', 'clickable', 'lastNode'],
|
||||
props: {
|
||||
item: {
|
||||
type: Object as PropType<INodeCreateElement>,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
},
|
||||
lastNode: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
:is="transitionsEnabled ? 'transition-group' : 'div'"
|
||||
class="item-iterator"
|
||||
name="accordion"
|
||||
@before-enter="beforeEnter"
|
||||
@enter="enter"
|
||||
@before-leave="beforeLeave"
|
||||
@leave="leave"
|
||||
>
|
||||
<div
|
||||
:is="transitionsEnabled ? 'transition-group' : 'div'"
|
||||
name="accordion"
|
||||
@before-enter="beforeEnter"
|
||||
@enter="enter"
|
||||
@before-leave="beforeLeave"
|
||||
@leave="leave"
|
||||
v-for="(item, index) in elements"
|
||||
:key="item.key"
|
||||
:class="item.type"
|
||||
:data-key="item.key"
|
||||
>
|
||||
<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>
|
||||
<creator-item
|
||||
: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>
|
||||
</template>
|
||||
@@ -33,7 +32,7 @@
|
||||
<script lang="ts">
|
||||
import { INodeCreateElement } from '@/Interface';
|
||||
|
||||
import Vue from 'vue';
|
||||
import Vue, { PropType } from 'vue';
|
||||
import CreatorItem from './CreatorItem.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
@@ -41,7 +40,20 @@ export default Vue.extend({
|
||||
components: {
|
||||
CreatorItem,
|
||||
},
|
||||
props: ['elements', 'activeIndex', 'disabled', 'transitionsEnabled'],
|
||||
props: {
|
||||
elements: {
|
||||
type: Array as PropType<INodeCreateElement[]>,
|
||||
},
|
||||
activeIndex: {
|
||||
type: Number,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
},
|
||||
transitionsEnabled: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
emit(eventName: string, element: INodeCreateElement, event: Event) {
|
||||
if (this.$props.disabled) {
|
||||
@@ -68,6 +80,9 @@ export default Vue.extend({
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.item-iterator > *:last-child {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
.accordion-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -2,176 +2,64 @@
|
||||
<div
|
||||
class="container"
|
||||
ref="mainPanelContainer"
|
||||
@click="onClickInside"
|
||||
>
|
||||
<SlideTransition>
|
||||
<SubcategoryPanel
|
||||
v-if="activeSubcategory"
|
||||
:elements="subcategorizedNodes"
|
||||
:title="activeSubcategory.properties.subcategory"
|
||||
:activeIndex="activeSubcategoryIndex"
|
||||
@close="onSubcategoryClose"
|
||||
@selected="selected"
|
||||
/>
|
||||
</SlideTransition>
|
||||
<div class="main-panel">
|
||||
<SearchBar
|
||||
v-model="nodeFilter"
|
||||
:eventBus="searchEventBus"
|
||||
@keydown.native="nodeFilterKeyDown"
|
||||
/>
|
||||
<div class="type-selector">
|
||||
<el-tabs v-model="selectedType" stretch>
|
||||
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.all')" :name="ALL_NODE_FILTER"></el-tab-pane>
|
||||
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.regular')" :name="REGULAR_NODE_FILTER"></el-tab-pane>
|
||||
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.trigger')" :name="TRIGGER_NODE_FILTER"></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<div v-if="searchFilter.length === 0" class="scrollable">
|
||||
<ItemIterator
|
||||
:elements="categorized"
|
||||
:disabled="!!activeSubcategory"
|
||||
:activeIndex="activeIndex"
|
||||
:transitionsEnabled="true"
|
||||
@selected="selected"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="scrollable"
|
||||
v-else-if="filteredNodeTypes.length > 0"
|
||||
<trigger-helper-panel
|
||||
v-if="selectedType === TRIGGER_NODE_FILTER"
|
||||
:searchItems="searchItems"
|
||||
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
|
||||
>
|
||||
<ItemIterator
|
||||
:elements="filteredNodeTypes"
|
||||
:activeIndex="activeIndex"
|
||||
@selected="selected"
|
||||
/>
|
||||
</div>
|
||||
<NoResults
|
||||
<type-selector slot="header" />
|
||||
</trigger-helper-panel>
|
||||
<categorized-items
|
||||
v-else
|
||||
@nodeTypeSelected="$emit('nodeTypeSelected', $event)"
|
||||
/>
|
||||
:searchItems="searchItems"
|
||||
:excludedSubcategories="[OTHER_TRIGGER_NODES_SUBCATEGORY]"
|
||||
:initialActiveCategories="[CORE_NODES_CATEGORY]"
|
||||
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
|
||||
>
|
||||
<type-selector slot="header" />
|
||||
</categorized-items>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import Vue from 'vue';
|
||||
|
||||
import { PropType } from 'vue';
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import ItemIterator from './ItemIterator.vue';
|
||||
import NoResults from './NoResults.vue';
|
||||
import SearchBar from './SearchBar.vue';
|
||||
import SubcategoryPanel from './SubcategoryPanel.vue';
|
||||
import { INodeCreateElement, INodeItemProps, ISubcategoryItemProps } from '@/Interface';
|
||||
import { ALL_NODE_FILTER, CORE_NODES_CATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER } from '@/constants';
|
||||
import SlideTransition from '../../transitions/SlideTransition.vue';
|
||||
import { matchesNodeType, matchesSelectType } from './helpers';
|
||||
import TriggerHelperPanel from './TriggerHelperPanel.vue';
|
||||
import { ALL_NODE_FILTER, TRIGGER_NODE_FILTER, OTHER_TRIGGER_NODES_SUBCATEGORY, CORE_NODES_CATEGORY } from '@/constants';
|
||||
import CategorizedItems from './CategorizedItems.vue';
|
||||
import TypeSelector from './TypeSelector.vue';
|
||||
import { INodeCreateElement } from '@/Interface';
|
||||
|
||||
export default mixins(externalHooks).extend({
|
||||
name: 'NodeCreateList',
|
||||
components: {
|
||||
ItemIterator,
|
||||
NoResults,
|
||||
SubcategoryPanel,
|
||||
SlideTransition,
|
||||
SearchBar,
|
||||
TriggerHelperPanel,
|
||||
CategorizedItems,
|
||||
TypeSelector,
|
||||
},
|
||||
props: {
|
||||
searchItems: {
|
||||
type: Array as PropType<INodeCreateElement[] | null>,
|
||||
},
|
||||
},
|
||||
props: ['categorizedItems', 'categoriesWithNodes', 'searchItems'],
|
||||
data() {
|
||||
return {
|
||||
activeCategory: [] as string[],
|
||||
activeSubcategory: null as INodeCreateElement | null,
|
||||
activeIndex: 1,
|
||||
activeSubcategoryIndex: 0,
|
||||
nodeFilter: '',
|
||||
selectedType: ALL_NODE_FILTER,
|
||||
searchEventBus: new Vue(),
|
||||
REGULAR_NODE_FILTER,
|
||||
CORE_NODES_CATEGORY,
|
||||
TRIGGER_NODE_FILTER,
|
||||
ALL_NODE_FILTER,
|
||||
OTHER_TRIGGER_NODES_SUBCATEGORY,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
searchFilter(): string {
|
||||
return this.nodeFilter.toLowerCase().trim();
|
||||
},
|
||||
filteredNodeTypes(): INodeCreateElement[] {
|
||||
const nodeTypes: INodeCreateElement[] = this.searchItems;
|
||||
const filter = this.searchFilter;
|
||||
const returnData = nodeTypes.filter((el: INodeCreateElement) => {
|
||||
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', {
|
||||
nodeFilter: this.nodeFilter,
|
||||
result: returnData,
|
||||
selectedType: this.selectedType,
|
||||
});
|
||||
}, 0);
|
||||
|
||||
return returnData;
|
||||
},
|
||||
|
||||
categorized() {
|
||||
return this.categorizedItems && this.categorizedItems
|
||||
.reduce((accu: INodeCreateElement[], el: INodeCreateElement) => {
|
||||
if (
|
||||
el.type !== 'category' &&
|
||||
!this.activeCategory.includes(el.category)
|
||||
) {
|
||||
return accu;
|
||||
}
|
||||
|
||||
if (!matchesSelectType(el, this.selectedType)) {
|
||||
return accu;
|
||||
}
|
||||
|
||||
if (el.type === 'category') {
|
||||
accu.push({
|
||||
...el,
|
||||
properties: {
|
||||
expanded: this.activeCategory.includes(el.category),
|
||||
},
|
||||
} as INodeCreateElement);
|
||||
return accu;
|
||||
}
|
||||
|
||||
accu.push(el);
|
||||
return accu;
|
||||
}, []);
|
||||
},
|
||||
|
||||
subcategorizedNodes() {
|
||||
const activeSubcategory = this.activeSubcategory as INodeCreateElement;
|
||||
const category = activeSubcategory.category;
|
||||
const subcategory = (activeSubcategory.properties as ISubcategoryItemProps).subcategory;
|
||||
|
||||
return activeSubcategory && this.categoriesWithNodes[category][subcategory]
|
||||
.nodes.filter((el: INodeCreateElement) => matchesSelectType(el, this.selectedType));
|
||||
selectedType(): string {
|
||||
return this.$store.getters['nodeCreator/selectedType'];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
nodeFilter(newValue, oldValue) {
|
||||
// Reset the index whenver the filter-value changes
|
||||
this.activeIndex = 0;
|
||||
this.$externalHooks().run('nodeCreateList.nodeFilterChanged', {
|
||||
oldValue,
|
||||
newValue,
|
||||
selectedType: this.selectedType,
|
||||
filteredNodes: this.filteredNodeTypes,
|
||||
});
|
||||
this.$telemetry.trackNodesPanel('nodeCreateList.nodeFilterChanged', {
|
||||
oldValue,
|
||||
newValue,
|
||||
selectedType: this.selectedType,
|
||||
filteredNodes: this.filteredNodeTypes,
|
||||
workflow_id: this.$store.getters.workflowId,
|
||||
});
|
||||
},
|
||||
selectedType(newValue, oldValue) {
|
||||
this.$externalHooks().run('nodeCreateList.selectedTypeChanged', {
|
||||
oldValue,
|
||||
@@ -184,170 +72,23 @@ export default mixins(externalHooks).extend({
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
nodeFilterKeyDown(e: KeyboardEvent) {
|
||||
if (!['Escape', 'Tab'].includes(e.key)) {
|
||||
// We only want to propagate 'Escape' as it closes the node-creator and
|
||||
// 'Tab' which toggles it
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (this.activeSubcategory) {
|
||||
const activeList = this.subcategorizedNodes;
|
||||
const activeNodeType = activeList[this.activeSubcategoryIndex];
|
||||
|
||||
if (e.key === 'ArrowDown' && this.activeSubcategory) {
|
||||
this.activeSubcategoryIndex++;
|
||||
this.activeSubcategoryIndex = Math.min(
|
||||
this.activeSubcategoryIndex,
|
||||
activeList.length - 1,
|
||||
);
|
||||
}
|
||||
else if (e.key === 'ArrowUp' && this.activeSubcategory) {
|
||||
this.activeSubcategoryIndex--;
|
||||
this.activeSubcategoryIndex = Math.max(this.activeSubcategoryIndex, 0);
|
||||
}
|
||||
else if (e.key === 'Enter') {
|
||||
this.selected(activeNodeType);
|
||||
}
|
||||
else if (e.key === 'ArrowLeft') {
|
||||
this.onSubcategoryClose();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let activeList;
|
||||
if (this.searchFilter.length > 0) {
|
||||
activeList = this.filteredNodeTypes;
|
||||
} else {
|
||||
activeList = this.categorized;
|
||||
}
|
||||
const activeNodeType = activeList[this.activeIndex];
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
this.activeIndex++;
|
||||
// Make sure that we stop at the last nodeType
|
||||
this.activeIndex = Math.min(
|
||||
this.activeIndex,
|
||||
activeList.length - 1,
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
this.activeIndex--;
|
||||
// Make sure that we do not get before the first nodeType
|
||||
this.activeIndex = Math.max(this.activeIndex, 0);
|
||||
} else if (e.key === 'Enter' && activeNodeType) {
|
||||
this.selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowRight' && activeNodeType && activeNodeType.type === 'subcategory') {
|
||||
this.selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowRight' && activeNodeType && activeNodeType.type === 'category' && !activeNodeType.properties.expanded) {
|
||||
this.selected(activeNodeType);
|
||||
} else if (e.key === 'ArrowLeft' && activeNodeType && activeNodeType.type === 'category' && activeNodeType.properties.expanded) {
|
||||
this.selected(activeNodeType);
|
||||
}
|
||||
},
|
||||
selected(element: INodeCreateElement) {
|
||||
if (element.type === 'node') {
|
||||
this.$emit('nodeTypeSelected', (element.properties as INodeItemProps).nodeType.name);
|
||||
} else if (element.type === 'category') {
|
||||
this.onCategorySelected(element.category);
|
||||
} else if (element.type === 'subcategory') {
|
||||
this.onSubcategorySelected(element);
|
||||
}
|
||||
},
|
||||
onCategorySelected(category: string) {
|
||||
if (this.activeCategory.includes(category)) {
|
||||
this.activeCategory = this.activeCategory.filter(
|
||||
(active: string) => active !== category,
|
||||
);
|
||||
} else {
|
||||
this.activeCategory = [...this.activeCategory, category];
|
||||
this.$telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', { category_name: category, workflow_id: this.$store.getters.workflowId });
|
||||
}
|
||||
|
||||
this.activeIndex = this.categorized.findIndex(
|
||||
(el: INodeCreateElement) => el.category === category,
|
||||
);
|
||||
},
|
||||
onSubcategorySelected(selected: INodeCreateElement) {
|
||||
this.activeSubcategoryIndex = 0;
|
||||
this.activeSubcategory = selected;
|
||||
this.$telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', { selected, workflow_id: this.$store.getters.workflowId });
|
||||
},
|
||||
|
||||
onSubcategoryClose() {
|
||||
this.activeSubcategory = null;
|
||||
this.activeSubcategoryIndex = 0;
|
||||
this.nodeFilter = '';
|
||||
},
|
||||
|
||||
onClickInside() {
|
||||
this.searchEventBus.$emit('focus');
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.$nextTick(() => {
|
||||
// initial opening effect
|
||||
this.activeCategory = [CORE_NODES_CATEGORY];
|
||||
});
|
||||
mounted() {
|
||||
this.$externalHooks().run('nodeCreateList.mounted');
|
||||
// Make sure tabs are visible on mount
|
||||
this.$store.commit('nodeCreator/setShowTabs', true);
|
||||
},
|
||||
async destroyed() {
|
||||
destroyed() {
|
||||
this.$store.commit('nodeCreator/setSelectedType', ALL_NODE_FILTER);
|
||||
this.$externalHooks().run('nodeCreateList.destroyed');
|
||||
this.$telemetry.trackNodesPanel('nodeCreateList.destroyed', { workflow_id: this.$store.getters.workflowId });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .el-tabs__item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::v-deep .el-tabs__active-bar {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
::v-deep .el-tabs__nav-wrap::after {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
|
||||
> div {
|
||||
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;
|
||||
}
|
||||
}
|
||||
.main-panel {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
<template>
|
||||
<div class="no-results">
|
||||
<div class="icon">
|
||||
<NoResultsIcon />
|
||||
<div :class="$style.noResults">
|
||||
<div :class="$style.icon" v-if="showIcon">
|
||||
<no-results-icon />
|
||||
</div>
|
||||
<div class="title">
|
||||
<div>
|
||||
{{ $locale.baseText('nodeCreator.noResults.weDidntMakeThatYet') }}
|
||||
</div>
|
||||
<div class="action">
|
||||
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
|
||||
<n8n-link @click="selectHttpRequest">{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}</n8n-link> {{ $locale.baseText('nodeCreator.noResults.or') }}
|
||||
<n8n-link @click="selectWebhook">{{ $locale.baseText('nodeCreator.noResults.webhook') }}</n8n-link> {{ $locale.baseText('nodeCreator.noResults.node') }}
|
||||
<div :class="$style.title">
|
||||
<slot name="title" />
|
||||
<div :class="$style.action">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="request">
|
||||
<div :class="$style.request" v-if="showRequest">
|
||||
<p v-text="$locale.baseText('nodeCreator.noResults.wantUsToMakeItFaster')" />
|
||||
<div>
|
||||
{{ $locale.baseText('nodeCreator.noResults.wantUsToMakeItFaster') }}
|
||||
</div>
|
||||
<div>
|
||||
<n8n-link
|
||||
:to="REQUEST_NODE_FORM_URL"
|
||||
>
|
||||
<n8n-link :to="REQUEST_NODE_FORM_URL">
|
||||
<span>{{ $locale.baseText('nodeCreator.noResults.requestTheNode') }}</span>
|
||||
<span>
|
||||
<font-awesome-icon
|
||||
class="external"
|
||||
:class="$style.external"
|
||||
icon="external-link-alt"
|
||||
:title="$locale.baseText('nodeCreator.noResults.requestTheNode')"
|
||||
/>
|
||||
@@ -38,12 +30,20 @@
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
import { HTTP_REQUEST_NODE_TYPE, REQUEST_NODE_FORM_URL, WEBHOOK_NODE_TYPE } from '@/constants';
|
||||
import { REQUEST_NODE_FORM_URL } from '@/constants';
|
||||
import Vue from 'vue';
|
||||
import NoResultsIcon from './NoResultsIcon.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'NoResults',
|
||||
props: {
|
||||
showRequest: {
|
||||
type: Boolean,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
NoResultsIcon,
|
||||
},
|
||||
@@ -52,20 +52,11 @@ export default Vue.extend({
|
||||
REQUEST_NODE_FORM_URL,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
selectWebhook() {
|
||||
this.$emit('nodeTypeSelected', WEBHOOK_NODE_TYPE);
|
||||
},
|
||||
|
||||
selectHttpRequest() {
|
||||
this.$emit('nodeTypeSelected', HTTP_REQUEST_NODE_TYPE);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.no-results {
|
||||
<style lang="scss" module>
|
||||
.noResults {
|
||||
background-color: $node-creator-no-results-background-color;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
@@ -75,27 +66,27 @@ export default Vue.extend({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
padding: 0 50px;
|
||||
padding: 0 var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
line-height: 22px;
|
||||
margin-top: 50px;
|
||||
font-size: var(--font-size-m);
|
||||
line-height: var(--font-line-height-regular);
|
||||
margin-top: var(--spacing-xs);
|
||||
|
||||
div {
|
||||
margin-bottom: 15px;
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
|
||||
.action, .request {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
font-size: var(--font-size-s);
|
||||
line-height: var(--font-line-height-compact);
|
||||
}
|
||||
|
||||
.request {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
bottom: var(--spacing-m);
|
||||
display: none;
|
||||
|
||||
@media (min-height: 550px) {
|
||||
@@ -104,13 +95,13 @@ export default Vue.extend({
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-top: 100px;
|
||||
margin-top: var(--spacing-2xl);
|
||||
min-height: 67px;
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
.external {
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
<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>
|
||||
<div>
|
||||
<aside :class="{'node-creator-scrim': true, expanded: !sidebarMenuCollapsed, active: showScrim}" />
|
||||
|
||||
<slide-transition>
|
||||
<div
|
||||
v-if="active"
|
||||
class="node-creator"
|
||||
ref="nodeCreator"
|
||||
v-click-outside="onClickOutside"
|
||||
@dragover="onDragOver"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<main-panel
|
||||
@nodeTypeSelected="nodeTypeSelected"
|
||||
:searchItems="searchItems"
|
||||
/>
|
||||
</div>
|
||||
</slide-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import Vue from 'vue';
|
||||
|
||||
import { ICategoriesWithNodes, INodeCreateElement } from '@/Interface';
|
||||
import { INodeCreateElement } from '@/Interface';
|
||||
import { INodeTypeDescription } from 'n8n-workflow';
|
||||
import SlideTransition from '../../transitions/SlideTransition.vue';
|
||||
|
||||
import MainPanel from './MainPanel.vue';
|
||||
import { getCategoriesWithNodes, getCategorizedList } from './helpers';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'NodeCreator',
|
||||
@@ -40,20 +40,16 @@ export default Vue.extend({
|
||||
active: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('users', ['personalizedNodeTypes']),
|
||||
allLatestNodeTypes(): INodeTypeDescription[] {
|
||||
return this.$store.getters['nodeTypes/allLatestNodeTypes'];
|
||||
showScrim(): boolean {
|
||||
return this.$store.getters['nodeCreator/showScrim'];
|
||||
},
|
||||
sidebarMenuCollapsed(): boolean {
|
||||
return this.$store.getters['ui/sidebarMenuCollapsed'];
|
||||
},
|
||||
visibleNodeTypes(): INodeTypeDescription[] {
|
||||
return this.allLatestNodeTypes.filter((nodeType) => !nodeType.hidden);
|
||||
},
|
||||
categoriesWithNodes(): ICategoriesWithNodes {
|
||||
return getCategoriesWithNodes(this.visibleNodeTypes, this.personalizedNodeTypes as string[]);
|
||||
},
|
||||
categorizedItems(): INodeCreateElement[] {
|
||||
return getCategorizedList(this.categoriesWithNodes);
|
||||
return this.$store.getters['nodeTypes/visibleNodeTypes'];
|
||||
},
|
||||
searchItems(): INodeCreateElement[] {
|
||||
const sorted = [...this.visibleNodeTypes];
|
||||
@@ -102,6 +98,11 @@ export default Vue.extend({
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
active(isActive) {
|
||||
if(isActive === false) this.$store.commit('nodeCreator/setShowScrim', false);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -113,20 +114,31 @@ export default Vue.extend({
|
||||
.node-creator {
|
||||
position: fixed;
|
||||
top: $header-height;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: $node-creator-width;
|
||||
height: 100%;
|
||||
background-color: $node-creator-background-color;
|
||||
z-index: 200;
|
||||
width: $node-creator-width;
|
||||
color: $node-creator-text-color;
|
||||
}
|
||||
|
||||
&:before {
|
||||
box-sizing: border-box;
|
||||
content: ' ';
|
||||
border-left: 1px solid $node-creator-border-color;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
.node-creator-scrim {
|
||||
position: fixed;
|
||||
top: $header-height;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: $sidebar-width;
|
||||
opacity: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-background-dark);
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
|
||||
&.expanded {
|
||||
left: $sidebar-expanded-width
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
draggable
|
||||
@dragstart="onDragStart"
|
||||
@dragend="onDragEnd"
|
||||
:class="{[$style['node-item']]: true, [$style.bordered]: bordered}"
|
||||
:class="{[$style['node-item']]: true}"
|
||||
>
|
||||
<NodeIcon :class="$style['node-icon']" :nodeType="nodeType" />
|
||||
<node-icon :class="$style['node-icon']" :nodeType="nodeType" />
|
||||
<div>
|
||||
<div :class="$style.details">
|
||||
<span :class="$style.name">
|
||||
@@ -16,7 +16,7 @@
|
||||
}}
|
||||
</span>
|
||||
<span v-if="isTrigger" :class="$style['trigger-icon']">
|
||||
<TriggerIcon />
|
||||
<trigger-icon />
|
||||
</span>
|
||||
<n8n-tooltip v-if="isCommunityNode" placement="top">
|
||||
<div
|
||||
@@ -45,7 +45,7 @@
|
||||
ref="draggable"
|
||||
v-show="dragging"
|
||||
>
|
||||
<NodeIcon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" />
|
||||
<node-icon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
@@ -54,26 +54,29 @@
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import {getNewNodePosition, NODE_SIZE} from '@/views/canvasHelpers';
|
||||
import Vue from 'vue';
|
||||
|
||||
import NodeIcon from '../../NodeIcon.vue';
|
||||
import TriggerIcon from '../../TriggerIcon.vue';
|
||||
import Vue, { PropType } from 'vue';
|
||||
import { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { getNewNodePosition, NODE_SIZE } from '@/views/canvasHelpers';
|
||||
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants';
|
||||
import { isCommunityPackageName } from '../../helpers';
|
||||
|
||||
Vue.component('NodeIcon', NodeIcon);
|
||||
Vue.component('TriggerIcon', TriggerIcon);
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
import TriggerIcon from '@/components/TriggerIcon.vue';
|
||||
import { isCommunityPackageName } from '@/components/helpers';
|
||||
|
||||
Vue.component('node-icon', NodeIcon);
|
||||
Vue.component('trigger-icon', TriggerIcon);
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'NodeItem',
|
||||
props: [
|
||||
'active',
|
||||
'filter',
|
||||
'nodeType',
|
||||
'bordered',
|
||||
],
|
||||
props: {
|
||||
nodeType: {
|
||||
type: Object as PropType<INodeTypeDescription>,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dragging: false,
|
||||
@@ -160,10 +163,7 @@ export default Vue.extend({
|
||||
margin-left: 15px;
|
||||
margin-right: 12px;
|
||||
display: flex;
|
||||
|
||||
&.bordered {
|
||||
border-bottom: 1px solid $node-creator-border-color;
|
||||
}
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.details {
|
||||
@@ -177,7 +177,7 @@ export default Vue.extend({
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
margin-right: 5px;
|
||||
@@ -189,7 +189,7 @@ export default Vue.extend({
|
||||
|
||||
.description {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-2xs);
|
||||
line-height: 16px;
|
||||
font-weight: 400;
|
||||
color: $node-creator-description-color;
|
||||
|
||||
@@ -1,40 +1,46 @@
|
||||
<template>
|
||||
<div class="search-container">
|
||||
<div :class="{ prefix: true, active: value.length > 0 }">
|
||||
<font-awesome-icon icon="search" />
|
||||
<div :class="$style.searchContainer">
|
||||
<div :class="{ [$style.prefix]: true, [$style.active]: value.length > 0 }">
|
||||
<font-awesome-icon icon="search" size="sm" />
|
||||
</div>
|
||||
<div class="text">
|
||||
<div :class="$style.text">
|
||||
<input
|
||||
:placeholder="$locale.baseText('nodeCreator.searchBar.searchNodes')"
|
||||
ref="input"
|
||||
:value="value"
|
||||
@input="onInput"
|
||||
:class="$style.input"
|
||||
/>
|
||||
</div>
|
||||
<div class="suffix" v-if="value.length > 0" @click="clear">
|
||||
<span class="clear el-icon-close clickable"></span>
|
||||
<div :class="$style.suffix" v-if="value.length > 0" @click="clear">
|
||||
<button :class="[$style.clear, $style.clickable]">
|
||||
<font-awesome-icon icon="times-circle" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
import Vue, { PropType } from 'vue';
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
|
||||
export default mixins(externalHooks).extend({
|
||||
name: "SearchBar",
|
||||
props: ["value", "eventBus"],
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
},
|
||||
eventBus: {
|
||||
type: Object as PropType<Vue>,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.$props.eventBus) {
|
||||
this.$props.eventBus.$on("focus", () => {
|
||||
this.focus();
|
||||
});
|
||||
if (this.eventBus) {
|
||||
this.eventBus.$on("focus", this.focus);
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.focus();
|
||||
}, 0);
|
||||
setTimeout(this.focus, 0);
|
||||
|
||||
this.$externalHooks().run('nodeCreator_searchBar.mount', { inputRef: this.$refs['input'] });
|
||||
},
|
||||
@@ -53,25 +59,37 @@ export default mixins(externalHooks).extend({
|
||||
this.$emit("input", "");
|
||||
},
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.eventBus) {
|
||||
this.eventBus.$off("focus", this.focus);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-container {
|
||||
<style lang="scss" module>
|
||||
.searchContainer {
|
||||
display: flex;
|
||||
height: 60px;
|
||||
height: 40px;
|
||||
padding: var(--spacing-s) var(--spacing-xs);
|
||||
align-items: center;
|
||||
padding-left: 14px;
|
||||
padding-right: 20px;
|
||||
border-bottom: 1px solid $node-creator-border-color;
|
||||
margin: var(--spacing-s);
|
||||
filter: drop-shadow(0px 2px 5px rgba(46, 46, 50, 0.04));
|
||||
|
||||
border: 1px solid $node-creator-border-color;
|
||||
background-color: $node-creator-search-background-color;
|
||||
color: $node-creator-search-placeholder-color;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--color-secondary)
|
||||
}
|
||||
}
|
||||
|
||||
.prefix {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
margin-right: 14px;
|
||||
font-size: var(--font-size-m);
|
||||
margin-right: var(--spacing-xs);
|
||||
|
||||
&.active {
|
||||
color: $color-primary !important;
|
||||
@@ -83,10 +101,10 @@ export default mixins(externalHooks).extend({
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: none !important;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 18px;
|
||||
-webkit-appearance: none;
|
||||
font-size: var(--font-size-s);
|
||||
appearance: none;
|
||||
background-color: var(--color-background-xlight);
|
||||
color: var(--color-text-dark);
|
||||
|
||||
@@ -99,32 +117,22 @@ export default mixins(externalHooks).extend({
|
||||
|
||||
.suffix {
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
text-align: right;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.clear {
|
||||
background-color: $node-creator-search-clear-background-color;
|
||||
border-radius: 50%;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
font-size: 16px;
|
||||
color: $node-creator-search-background-color;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: $node-creator-search-clear-color;
|
||||
padding: 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $node-creator-search-clear-background-color-hover;
|
||||
svg path {
|
||||
fill: $node-creator-search-clear-background-color;
|
||||
}
|
||||
|
||||
&:before {
|
||||
line-height: 16px;
|
||||
display: flex;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
font-size: 15px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover svg path {
|
||||
fill: $node-creator-search-clear-background-color-hover;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div :class="$style.subcategory">
|
||||
<div :class="{[$style.subcategory]: true, [$style.subcategoryWithIcon]: hasIcon}">
|
||||
<node-icon v-if="hasIcon" :class="$style.subcategoryIcon" :nodeType="itemProperties" />
|
||||
<div :class="$style.details">
|
||||
<div :class="$style.title">
|
||||
{{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }}
|
||||
@@ -15,14 +16,30 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import Vue, { PropType } from 'vue';
|
||||
import camelcase from 'lodash.camelcase';
|
||||
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
import { INodeCreateElement, ISubcategoryItemProps } from '@/Interface';
|
||||
export default Vue.extend({
|
||||
props: ['item'],
|
||||
components: {
|
||||
NodeIcon,
|
||||
},
|
||||
props: {
|
||||
item: {
|
||||
type: Object as PropType<INodeCreateElement>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
subcategoryName() {
|
||||
return camelcase(this.item.properties.subcategory);
|
||||
itemProperties() : ISubcategoryItemProps {
|
||||
return this.item.properties as ISubcategoryItemProps;
|
||||
},
|
||||
subcategoryName(): string {
|
||||
return camelcase(this.itemProperties.subcategory);
|
||||
},
|
||||
hasIcon(): boolean {
|
||||
return this.itemProperties.icon !== undefined || this.itemProperties.iconData !== undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -30,11 +47,23 @@ export default Vue.extend({
|
||||
|
||||
|
||||
<style lang="scss" module>
|
||||
.subcategoryIcon {
|
||||
min-width: 26px;
|
||||
max-width: 26px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.subcategory {
|
||||
display: flex;
|
||||
padding: 11px 16px 11px 30px;
|
||||
}
|
||||
|
||||
.subcategoryWithIcon {
|
||||
margin-left: 15px;
|
||||
margin-right: 12px;
|
||||
padding: 11px 8px 11px 0;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex-grow: 1;
|
||||
margin-right: 4px;
|
||||
@@ -42,13 +71,13 @@ export default Vue.extend({
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: 16px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-2xs);
|
||||
line-height: 16px;
|
||||
font-weight: 400;
|
||||
color: $node-creator-description-color;
|
||||
@@ -57,6 +86,7 @@ export default Vue.extend({
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<template>
|
||||
<div class="subcategory-panel">
|
||||
<div class="subcategory-header">
|
||||
<div class="clickable" @click="onBackArrowClick">
|
||||
<font-awesome-icon class="back-arrow" icon="arrow-left" />
|
||||
</div>
|
||||
<span>
|
||||
{{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="scrollable">
|
||||
<ItemIterator
|
||||
:elements="elements"
|
||||
:activeIndex="activeIndex"
|
||||
@selected="$emit('selected', $event)"
|
||||
@dragstart="$emit('dragstart', $event)"
|
||||
@dragend="$emit('dragend', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import camelcase from 'lodash.camelcase';
|
||||
import Vue from 'vue';
|
||||
|
||||
import ItemIterator from './ItemIterator.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'SubcategoryPanel',
|
||||
components: {
|
||||
ItemIterator,
|
||||
},
|
||||
props: ['title', 'elements', 'activeIndex'],
|
||||
computed: {
|
||||
subcategoryName() {
|
||||
return camelcase(this.title);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onBackArrowClick() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.subcategory-panel {
|
||||
position: absolute;
|
||||
background: $node-creator-search-background-color;
|
||||
z-index: 100;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&:before {
|
||||
box-sizing: border-box;
|
||||
content: ' ';
|
||||
border-left: 1px solid $node-creator-border-color;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.subcategory-header {
|
||||
border: $node-creator-border-color solid 1px;
|
||||
height: 50px;
|
||||
background-color: $node-creator-subcategory-panel-header-bacground-color;
|
||||
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 11px 15px;
|
||||
}
|
||||
|
||||
.back-arrow {
|
||||
color: $node-creator-arrow-color;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
height: calc(100% - 100px);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div :class="{ [$style.triggerHelperContainer]: true, [$style.isRoot]: isRoot }">
|
||||
<categorized-items
|
||||
ref="categorizedItems"
|
||||
@subcategoryClose="onSubcategoryClose"
|
||||
@onSubcategorySelected="onSubcategorySelected"
|
||||
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
|
||||
:initialActiveIndex="0"
|
||||
:searchItems="searchItems"
|
||||
:firstLevelItems="isRoot ? items : []"
|
||||
:excludedCategories="[CORE_NODES_CATEGORY]"
|
||||
:initialActiveCategories="[COMMUNICATION_CATEGORY]"
|
||||
>
|
||||
<template #header>
|
||||
<slot name="header" />
|
||||
<p v-if="isRoot" v-text="$locale.baseText('nodeCreator.triggerHelperPanel.title')" :class="$style.title" />
|
||||
</template>
|
||||
</categorized-items>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { PropType } from 'vue';
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
import { INodeCreateElement } from '@/Interface';
|
||||
import { CORE_NODES_CATEGORY, CRON_NODE_TYPE, WEBHOOK_NODE_TYPE, OTHER_TRIGGER_NODES_SUBCATEGORY, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, COMMUNICATION_CATEGORY } from '@/constants';
|
||||
|
||||
import ItemIterator from './ItemIterator.vue';
|
||||
import CategorizedItems from './CategorizedItems.vue';
|
||||
import SearchBar from './SearchBar.vue';
|
||||
|
||||
export default mixins(externalHooks).extend({
|
||||
name: 'TriggerHelperPanel',
|
||||
components: {
|
||||
ItemIterator,
|
||||
CategorizedItems,
|
||||
SearchBar,
|
||||
},
|
||||
props: {
|
||||
searchItems: {
|
||||
type: Array as PropType<INodeCreateElement[] | null>,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
CORE_NODES_CATEGORY,
|
||||
COMMUNICATION_CATEGORY,
|
||||
isRoot: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
items() {
|
||||
return [{
|
||||
key: "core_nodes",
|
||||
type: "subcategory",
|
||||
title: this.$locale.baseText('nodeCreator.subcategoryNames.appTriggerNodes'),
|
||||
properties: {
|
||||
subcategory: "App Trigger Nodes",
|
||||
description: this.$locale.baseText('nodeCreator.subcategoryDescriptions.appTriggerNodes'),
|
||||
icon: "fa:satellite-dish",
|
||||
defaults: {
|
||||
color: "#7D838F",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: CRON_NODE_TYPE,
|
||||
type: "node",
|
||||
properties: {
|
||||
nodeType: {
|
||||
|
||||
group: [],
|
||||
name: CRON_NODE_TYPE,
|
||||
displayName: this.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName'),
|
||||
description: this.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDescription'),
|
||||
icon: "fa:clock",
|
||||
defaults: {
|
||||
color: "#7D838F",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: WEBHOOK_NODE_TYPE,
|
||||
type: "node",
|
||||
properties: {
|
||||
nodeType: {
|
||||
group: [],
|
||||
name: WEBHOOK_NODE_TYPE,
|
||||
displayName: this.$locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDisplayName'),
|
||||
description: this.$locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDescription'),
|
||||
iconData: {
|
||||
type: "file",
|
||||
icon: "webhook",
|
||||
fileBuffer: "/static/webhook-icon.svg",
|
||||
},
|
||||
defaults: {
|
||||
color: "#7D838F",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MANUAL_TRIGGER_NODE_TYPE,
|
||||
type: "node",
|
||||
properties: {
|
||||
nodeType: {
|
||||
group: [],
|
||||
name: MANUAL_TRIGGER_NODE_TYPE,
|
||||
displayName: this.$locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
|
||||
description: this.$locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDescription'),
|
||||
icon: "fa:mouse-pointer",
|
||||
defaults: {
|
||||
color: "#7D838F",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
type: "node",
|
||||
properties: {
|
||||
nodeType: {
|
||||
group: [],
|
||||
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||
displayName: this.$locale.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDisplayName'),
|
||||
description: this.$locale.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDescription'),
|
||||
icon: "fa:sign-out-alt",
|
||||
defaults: {
|
||||
color: "#7D838F",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "subcategory",
|
||||
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
|
||||
category: CORE_NODES_CATEGORY,
|
||||
properties: {
|
||||
subcategory: OTHER_TRIGGER_NODES_SUBCATEGORY,
|
||||
description: this.$locale.baseText('nodeCreator.subcategoryDescriptions.otherTriggerNodes'),
|
||||
icon: "fa:folder-open",
|
||||
defaults: {
|
||||
color: "#7D838F",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isRootSubcategory(subcategory: INodeCreateElement) {
|
||||
return this.items.find(item => item.key === subcategory.key) !== undefined;
|
||||
},
|
||||
onSubcategorySelected() {
|
||||
this.isRoot = false;
|
||||
},
|
||||
onSubcategoryClose(subcategory: INodeCreateElement) {
|
||||
this.isRoot = this.isRootSubcategory(subcategory);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.triggerHelperContainer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Remove node item border on the root level
|
||||
&.isRoot {
|
||||
--node-item-border: none;
|
||||
}
|
||||
}
|
||||
.itemCreator {
|
||||
height: calc(100% - 120px);
|
||||
padding-top: 1px;
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-l);
|
||||
line-height: var(--font-line-height-xloose);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-dark);
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-3xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="type-selector" v-if="showTabs">
|
||||
<el-tabs stretch :value="selectedType" @input="setType">
|
||||
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.all')" :name="ALL_NODE_FILTER"></el-tab-pane>
|
||||
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.regular')" :name="REGULAR_NODE_FILTER"></el-tab-pane>
|
||||
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.trigger')" :name="TRIGGER_NODE_FILTER"></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ALL_NODE_FILTER, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER } from '@/constants';
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'NodeCreateTypeSelector',
|
||||
data() {
|
||||
return {
|
||||
REGULAR_NODE_FILTER,
|
||||
TRIGGER_NODE_FILTER,
|
||||
ALL_NODE_FILTER,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
setType(type: string) {
|
||||
this.$store.commit('nodeCreator/setSelectedType', type);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
showTabs(): boolean {
|
||||
return this.$store.getters['nodeCreator/showTabs'];
|
||||
},
|
||||
selectedType(): string {
|
||||
return this.$store.getters['nodeCreator/selectedType'];
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
::v-deep .el-tabs__item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::v-deep .el-tabs__active-bar {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
::v-deep .el-tabs__nav-wrap::after {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.type-selector {
|
||||
text-align: center;
|
||||
background-color: $node-creator-select-background-color;
|
||||
|
||||
::v-deep .el-tabs > div {
|
||||
margin-bottom: 0;
|
||||
|
||||
.el-tabs__nav {
|
||||
height: 43px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,145 +1,7 @@
|
||||
import { CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, SUBCATEGORY_DESCRIPTIONS, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER, ALL_NODE_FILTER, PERSONALIZED_CATEGORY } from '@/constants';
|
||||
import { INodeCreateElement, ICategoriesWithNodes, INodeItemProps } from '@/Interface';
|
||||
import { REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER, ALL_NODE_FILTER } from '@/constants';
|
||||
import { INodeCreateElement, INodeItemProps } from '@/Interface';
|
||||
import { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
const addNodeToCategory = (accu: ICategoriesWithNodes, nodeType: INodeTypeDescription, category: string, subcategory: string) => {
|
||||
if (!accu[category]) {
|
||||
accu[category] = {};
|
||||
}
|
||||
if (!accu[category][subcategory]) {
|
||||
accu[category][subcategory] = {
|
||||
triggerCount: 0,
|
||||
regularCount: 0,
|
||||
nodes: [],
|
||||
};
|
||||
}
|
||||
const isTrigger = nodeType.group.includes('trigger');
|
||||
if (isTrigger) {
|
||||
accu[category][subcategory].triggerCount++;
|
||||
}
|
||||
if (!isTrigger) {
|
||||
accu[category][subcategory].regularCount++;
|
||||
}
|
||||
accu[category][subcategory].nodes.push({
|
||||
type: 'node',
|
||||
key: `${category}_${nodeType.name}`,
|
||||
category,
|
||||
properties: {
|
||||
nodeType,
|
||||
subcategory,
|
||||
},
|
||||
includedByTrigger: isTrigger,
|
||||
includedByRegular: !isTrigger,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[], personalizedNodeTypes: string[]): ICategoriesWithNodes => {
|
||||
const sorted = [...nodeTypes].sort((a: INodeTypeDescription, b: INodeTypeDescription) => a.displayName > b.displayName? 1 : -1);
|
||||
return sorted.reduce(
|
||||
(accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => {
|
||||
if (personalizedNodeTypes.includes(nodeType.name)) {
|
||||
addNodeToCategory(accu, nodeType, PERSONALIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
|
||||
}
|
||||
|
||||
if (!nodeType.codex || !nodeType.codex.categories) {
|
||||
addNodeToCategory(accu, nodeType, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY);
|
||||
return accu;
|
||||
}
|
||||
|
||||
nodeType.codex.categories.forEach((_category: string) => {
|
||||
const category = _category.trim();
|
||||
const subcategory =
|
||||
nodeType.codex &&
|
||||
nodeType.codex.subcategories &&
|
||||
nodeType.codex.subcategories[category]
|
||||
? nodeType.codex.subcategories[category][0]
|
||||
: UNCATEGORIZED_SUBCATEGORY;
|
||||
|
||||
addNodeToCategory(accu, nodeType, category, subcategory);
|
||||
});
|
||||
return accu;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
|
||||
const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => {
|
||||
const excludeFromSort = [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, UNCATEGORIZED_CATEGORY, PERSONALIZED_CATEGORY];
|
||||
const categories = Object.keys(categoriesWithNodes);
|
||||
const sorted = categories.filter(
|
||||
(category: string) =>
|
||||
!excludeFromSort.includes(category),
|
||||
);
|
||||
sorted.sort();
|
||||
|
||||
return [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, PERSONALIZED_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY];
|
||||
};
|
||||
|
||||
export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): INodeCreateElement[] => {
|
||||
const categories = getCategories(categoriesWithNodes);
|
||||
|
||||
return categories.reduce(
|
||||
(accu: INodeCreateElement[], category: string) => {
|
||||
if (!categoriesWithNodes[category]) {
|
||||
return accu;
|
||||
}
|
||||
|
||||
const categoryEl: INodeCreateElement = {
|
||||
type: 'category',
|
||||
key: category,
|
||||
category,
|
||||
properties: {
|
||||
expanded: false,
|
||||
},
|
||||
};
|
||||
|
||||
const subcategories = Object.keys(categoriesWithNodes[category]);
|
||||
if (subcategories.length === 1) {
|
||||
const subcategory = categoriesWithNodes[category][
|
||||
subcategories[0]
|
||||
];
|
||||
if (subcategory.triggerCount > 0) {
|
||||
categoryEl.includedByTrigger = subcategory.triggerCount > 0;
|
||||
}
|
||||
if (subcategory.regularCount > 0) {
|
||||
categoryEl.includedByRegular = subcategory.regularCount > 0;
|
||||
}
|
||||
return [...accu, categoryEl, ...subcategory.nodes];
|
||||
}
|
||||
|
||||
subcategories.sort();
|
||||
const subcategorized = subcategories.reduce(
|
||||
(accu: INodeCreateElement[], subcategory: string) => {
|
||||
const subcategoryEl: INodeCreateElement = {
|
||||
type: 'subcategory',
|
||||
key: `${category}_${subcategory}`,
|
||||
category,
|
||||
properties: {
|
||||
subcategory,
|
||||
description: SUBCATEGORY_DESCRIPTIONS[category][subcategory],
|
||||
},
|
||||
includedByTrigger: categoriesWithNodes[category][subcategory].triggerCount > 0,
|
||||
includedByRegular: categoriesWithNodes[category][subcategory].regularCount > 0,
|
||||
};
|
||||
|
||||
if (subcategoryEl.includedByTrigger) {
|
||||
categoryEl.includedByTrigger = true;
|
||||
}
|
||||
if (subcategoryEl.includedByRegular) {
|
||||
categoryEl.includedByRegular = true;
|
||||
}
|
||||
|
||||
accu.push(subcategoryEl);
|
||||
return accu;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return [...accu, categoryEl, ...subcategorized];
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
export const matchesSelectType = (el: INodeCreateElement, selectedType: string) => {
|
||||
if (selectedType === REGULAR_NODE_FILTER && el.includedByRegular) {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { WEBHOOK_NODE_TYPE } from '@/constants';
|
||||
import { WEBHOOK_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import { INodeUi } from '@/Interface';
|
||||
import { INodeTypeDescription } from 'n8n-workflow';
|
||||
import mixins from 'vue-typed-mixins';
|
||||
@@ -72,7 +72,10 @@ export default mixins(
|
||||
return this.$store.getters.isActionActive('workflowRunning');
|
||||
},
|
||||
isTriggerNode (): boolean {
|
||||
return !!(this.nodeType && this.nodeType.group.includes('trigger'));
|
||||
return this.$store.getters['nodeTypes/isTriggerNode'](this.node.type);
|
||||
},
|
||||
isManualTriggerNode (): boolean {
|
||||
return Boolean(this.nodeType && this.nodeType.name === MANUAL_TRIGGER_NODE_TYPE);
|
||||
},
|
||||
isPollingTypeNode (): boolean {
|
||||
return !!(this.nodeType && this.nodeType.polling);
|
||||
@@ -138,7 +141,7 @@ export default mixins(
|
||||
return this.$locale.baseText('ndv.execute.fetchEvent');
|
||||
}
|
||||
|
||||
if (this.isTriggerNode && !this.isScheduleTrigger) {
|
||||
if (this.isTriggerNode && !this.isScheduleTrigger && !this.isManualTriggerNode) {
|
||||
return this.$locale.baseText('ndv.execute.listenForEvent');
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ export default mixins(
|
||||
return null;
|
||||
},
|
||||
isTriggerNode (): boolean {
|
||||
return !!(this.nodeType && this.nodeType.group.includes('trigger'));
|
||||
return this.$store.getters['nodeTypes/isTriggerNode'](this.node.type);
|
||||
},
|
||||
isPollingTypeNode (): boolean {
|
||||
return !!(this.nodeType && this.nodeType.polling);
|
||||
@@ -150,6 +150,8 @@ export default mixins(
|
||||
return executionData.resultData.runData;
|
||||
},
|
||||
hasNodeRun(): boolean {
|
||||
if (this.$store.getters.subworkflowExecutionError) return true;
|
||||
|
||||
return Boolean(
|
||||
this.node && this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name),
|
||||
);
|
||||
|
||||
@@ -158,6 +158,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="paneType === 'output' && hasSubworkflowExecutionError" :class="$style.stretchVertically">
|
||||
<NodeErrorView :error="subworkflowExecutionError" :class="$style.errorDisplay" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasNodeRun" :class="$style.center">
|
||||
<slot name="node-not-run"></slot>
|
||||
</div>
|
||||
@@ -499,7 +503,7 @@ export default mixins(
|
||||
return null;
|
||||
},
|
||||
isTriggerNode (): boolean {
|
||||
return !!(this.nodeType && this.nodeType.group.includes('trigger'));
|
||||
return this.$store.getters['nodeTypes/isTriggerNode'](this.node.type);
|
||||
},
|
||||
canPinData (): boolean {
|
||||
return !this.isPaneTypeInput &&
|
||||
@@ -522,6 +526,12 @@ export default mixins(
|
||||
hasNodeRun(): boolean {
|
||||
return Boolean(!this.isExecuting && this.node && (this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name) || this.hasPinData));
|
||||
},
|
||||
subworkflowExecutionError(): Error | null {
|
||||
return this.$store.getters.subworkflowExecutionError;
|
||||
},
|
||||
hasSubworkflowExecutionError(): boolean {
|
||||
return Boolean(this.subworkflowExecutionError);
|
||||
},
|
||||
hasRunError(): boolean {
|
||||
return Boolean(this.node && this.workflowRunData && this.workflowRunData[this.node.name] && this.workflowRunData[this.node.name][this.runIndex] && this.workflowRunData[this.node.name][this.runIndex].error);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
:loading="isSaving"
|
||||
:disabled="disabled"
|
||||
:class="$style.button"
|
||||
:type="type"
|
||||
@click="$emit('click')"
|
||||
/>
|
||||
</span>
|
||||
@@ -36,6 +37,10 @@ export default Vue.extend({
|
||||
savedLabel: {
|
||||
type: String,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
saveButtonLabel() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER } from '@/constants';
|
||||
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER, NON_ACTIVATABLE_TRIGGER_NODE_TYPES } from '@/constants';
|
||||
import { INodeUi, ITemplatesNode } from '@/Interface';
|
||||
import { isResourceLocatorValue } from '@/typeGuards';
|
||||
import dateformat from 'dateformat';
|
||||
@@ -49,10 +49,7 @@ export function getTriggerNodeServiceName(nodeType: INodeTypeDescription): strin
|
||||
}
|
||||
|
||||
export function getActivatableTriggerNodes(nodes: INodeUi[]) {
|
||||
return nodes.filter((node: INodeUi) => {
|
||||
// Error Trigger does not behave like other triggers and workflows using it can not be activated
|
||||
return !node.disabled && node.type !== ERROR_TRIGGER_NODE_TYPE;
|
||||
});
|
||||
return nodes.filter((node: INodeUi) => !node.disabled && !NON_ACTIVATABLE_TRIGGER_NODE_TYPES.includes(node.type));
|
||||
}
|
||||
|
||||
export function filterTemplateNodes(nodes: ITemplatesNode[]) {
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
registerCustomAction(key: string, action: Function) {
|
||||
this.customActions[key] = action;
|
||||
},
|
||||
unregisterCustomAction(key: string) {
|
||||
Vue.delete(this.customActions, key);
|
||||
},
|
||||
delegateClick(e: MouseEvent) {
|
||||
const clickedElement = e.target;
|
||||
if (!(clickedElement instanceof Element) || clickedElement.tagName !== 'A') return;
|
||||
|
||||
@@ -29,13 +29,31 @@ export const nodeBase = mixins(
|
||||
return this.data.id;
|
||||
},
|
||||
},
|
||||
props: [
|
||||
'name',
|
||||
'instance',
|
||||
'isReadOnly',
|
||||
'isActive',
|
||||
'hideActions',
|
||||
],
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
},
|
||||
instance: {
|
||||
// We can't use PropType<jsPlumbInstance> here because the version of jsplumb doesn't
|
||||
// include correct typing for draggable instance(`clearDragSelection`, `destroyDraggable`, etc.)
|
||||
type: Object,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
},
|
||||
hideActions: {
|
||||
type: Boolean,
|
||||
},
|
||||
disableSelecting: {
|
||||
type: Boolean,
|
||||
},
|
||||
showCustomTooltip: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
__addInputEndpoints (node: INodeUi, nodeTypeData: INodeTypeDescription) {
|
||||
// Add Inputs
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IDataObject,
|
||||
INodeTypeNameVersion,
|
||||
IWorkflowBase,
|
||||
SubworkflowOperationError,
|
||||
TelemetryHelpers,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
@@ -291,19 +292,34 @@ export const pushConnection = mixins(
|
||||
|
||||
}
|
||||
|
||||
let title: string;
|
||||
if (runDataExecuted.data.resultData.lastNodeExecuted) {
|
||||
title = `Problem in node ‘${runDataExecuted.data.resultData.lastNodeExecuted}‘`;
|
||||
if (runDataExecuted.data.resultData.error?.name === 'SubworkflowOperationError') {
|
||||
const error = runDataExecuted.data.resultData.error as SubworkflowOperationError;
|
||||
|
||||
this.$store.commit('setSubworkflowExecutionError', error);
|
||||
|
||||
this.$showMessage({
|
||||
title: error.message,
|
||||
message: error.description,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
} else {
|
||||
title = 'Problem executing workflow';
|
||||
let title: string;
|
||||
if (runDataExecuted.data.resultData.lastNodeExecuted) {
|
||||
title = `Problem in node ‘${runDataExecuted.data.resultData.lastNodeExecuted}‘`;
|
||||
} else {
|
||||
title = 'Problem executing workflow';
|
||||
}
|
||||
|
||||
this.$showMessage({
|
||||
title,
|
||||
message: runDataExecutedErrorMessage,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
this.$showMessage({
|
||||
title,
|
||||
message: runDataExecutedErrorMessage,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
} else {
|
||||
// Workflow did execute without a problem
|
||||
this.$titleSet(workflow.name as string, 'IDLE');
|
||||
|
||||
@@ -38,6 +38,8 @@ export const workflowRun = mixins(
|
||||
);
|
||||
}
|
||||
|
||||
this.$store.commit('setSubworkflowExecutionError', null);
|
||||
|
||||
this.$store.commit('addActiveAction', 'workflowRunning');
|
||||
|
||||
let response: IExecutionPushResponse;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
@@ -21,4 +21,5 @@ export default Vue.extend({
|
||||
.slide-enter {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user