Change the UI of the Nodes Panel (#1855)

* Add codex search properties to node types

* implement basic styles

* update header designs

* update node list designs

* add trigger icon

* refactor node creator list

* implement categories and subcategories

* fix up spacing

* add arrows

* implement navigatable list

* implement more of feature

* implement navigation

* add transitions

* fix lint issues

* fix overlay

*  Get and add codex categories

* fix up design

* update borders

* implement no-matches view

* fix preview bug

* add color to search

* clean up borders

* add comma

* Revert "Merge branch 'add-codex-data' of github.com:n8n-io/n8n into PROD-819-nodes-panel-redesign"

38b7d7ead1

* use new impl

* remove empty categories

* update scrolling, hide start node

* make scrollable

* remove text while subcategory panel is open

* fix up spacing

* fix lint issues

* update descriptions

* update path

* update images

* fix tags manager

* give min height to image

* gst

* update clear color

* update font size

* fix firefox spacing

* close on click outside

* add external link icon

* update iterator key

* add client side caching for images

* update caching header

* ️ Add properties to codex for nodes panel (#1854)

*  Get and add codex categories

*  Add parens to evaluation + destructuring

* 🔥 Remove non-existing class reference

*  Add alias to codex property

* move constants

* 🔨 Rename CodexCategories to CodexData

* ✏️ Update getCodex documentation

* refactor and move

* refactor no results view

* more refactoring

* refactor subcategory panel

* more refactoring

* update text

* update no results view

* add miscellaneous to end of list

* address design feedback

* reimplement node search

* fix up clear

* update placeholder color

* impl transition

* focus on tab

* update spacing

* fix transition bug on start

* fix up x

* fix position

* build

* safari fix

* remove input changes

* css bleed issue with image

* update css value

* clean up

* simplify impl

* rename again

* rename again

* rename all

* fix hover bug

* remove keep alive

* delete icon

* update interface type

* refactor components

* update scss to module

* clean up impl

* clean up colors as vars

* fix indentation

* clean up scss

* clean up scss

* clean up scss

* clean up scss

* Clean up files

* update logic to be more efficient

* fix search bug

* update type

* remove unused

* clean up js

* update scrollable, border impl, transition

* fix simicolon

* build

* update search

* address max's comments

* change icon border radius

* change margin

* update icon size

* update icon size

* update slide transition out

* add comma

* remove full

* update trigger icon size

* fix image size

* address design feedback

* update external link icons

* address codacy issues

* support custom nodes without codex file

* address jan's feedback

* address Ben's comments

* add subcategory index

* open/close categories with arrow keys

* add lint comment

* Address latest comments

*  Minor changes

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Ben Hesseldieck 2021-06-18 07:58:26 +02:00 committed by GitHub
parent 6e68d71f4d
commit 0470740737
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1549 additions and 375 deletions

View file

@ -3,6 +3,7 @@ import {
UserSettings,
} from 'n8n-core';
import {
CodexData,
ICredentialType,
ILogger,
INodeType,
@ -25,6 +26,8 @@ import {
import * as glob from 'glob-promise';
import * as path from 'path';
const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
class LoadNodesAndCredentialsClass {
nodeTypes: INodeTypeData = {};
@ -133,7 +136,6 @@ class LoadNodesAndCredentialsClass {
* @param {string} credentialName The name of the credentials
* @param {string} filePath The file to read credentials from
* @returns {Promise<void>}
* @memberof N8nPackagesInformationClass
*/
async loadCredentialsFromFile(credentialName: string, filePath: string): Promise<void> {
const tempModule = require(filePath);
@ -160,7 +162,6 @@ class LoadNodesAndCredentialsClass {
* @param {string} nodeName Tha name of the node
* @param {string} filePath The file to read node from
* @returns {Promise<void>}
* @memberof N8nPackagesInformationClass
*/
async loadNodeFromFile(packageName: string, nodeName: string, filePath: string): Promise<void> {
let tempNode: INodeType;
@ -169,6 +170,7 @@ class LoadNodesAndCredentialsClass {
const tempModule = require(filePath);
try {
tempNode = new tempModule[nodeName]() as INodeType;
this.addCodex({ node: tempNode, filePath, isCustom: packageName === 'CUSTOM' });
} catch (error) {
console.error(`Error loading node "${nodeName}" from: "${filePath}"`);
throw error;
@ -202,6 +204,57 @@ class LoadNodesAndCredentialsClass {
};
}
/**
* Retrieves `categories`, `subcategories` and alias (if defined)
* from the codex data for the node at the given file path.
*
* @param {string} filePath The file path to a `*.node.js` file
* @returns {CodexData}
*/
getCodex(filePath: string): CodexData {
const { categories, subcategories, alias } = require(`${filePath}on`); // .js to .json
return {
...(categories && { categories }),
...(subcategories && { subcategories }),
...(alias && { alias }),
};
}
/**
* Adds a node codex `categories` and `subcategories` (if defined)
* to a node description `codex` property.
*
* @param {object} obj
* @param obj.node Node to add categories to
* @param obj.filePath Path to the built node
* @param obj.isCustom Whether the node is custom
* @returns {void}
*/
addCodex({ node, filePath, isCustom }: {
node: INodeType;
filePath: string;
isCustom: boolean;
}) {
try {
const codex = this.getCodex(filePath);
if (isCustom) {
codex.categories = codex.categories
? codex.categories.concat(CUSTOM_NODES_CATEGORY)
: [CUSTOM_NODES_CATEGORY];
}
node.description.codex = codex;
} catch (_) {
this.logger.debug(`No codex available for: ${filePath.split('/').pop()}`);
if (isCustom) {
node.description.codex = {
categories: [CUSTOM_NODES_CATEGORY],
};
}
}
}
/**
* Loads nodes and credentials from the given directory
@ -209,7 +262,6 @@ class LoadNodesAndCredentialsClass {
* @param {string} setPackageName The package name to set for the found nodes
* @param {string} directory The directory to look in
* @returns {Promise<void>}
* @memberof N8nPackagesInformationClass
*/
async loadDataFromDirectory(setPackageName: string, directory: string): Promise<void> {
const files = await glob(path.join(directory, '**/*\.@(node|credentials)\.js'));
@ -237,7 +289,6 @@ class LoadNodesAndCredentialsClass {
*
* @param {string} packageName The name to read data from
* @returns {Promise<void>}
* @memberof N8nPackagesInformationClass
*/
async loadDataFromPackage(packageName: string): Promise<void> {
// Get the absolute path of the package

View file

@ -947,6 +947,9 @@ class App {
const filepath = nodeType.description.icon.substr(5);
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
res.setHeader('Cache-control', `private max-age=${maxAge}`);
res.sendFile(filepath);
});

View file

@ -489,6 +489,39 @@ export interface ILinkMenuItemProperties {
newWindow?: boolean;
}
export interface ISubcategoryItemProps {
subcategory: string;
description: string;
}
export interface INodeItemProps {
subcategory: string;
nodeType: INodeTypeDescription;
}
export interface ICategoryItemProps {
expanded: boolean;
}
export interface INodeCreateElement {
type: 'node' | 'category' | 'subcategory';
category: string;
key: string;
includedByTrigger?: boolean;
includedByRegular?: boolean;
properties: ISubcategoryItemProps | INodeItemProps | ICategoryItemProps;
}
export interface ICategoriesWithNodes {
[category: string]: {
[subcategory: string]: {
regularCount: number;
triggerCount: number;
nodes: INodeCreateElement[];
};
};
}
export interface ITag {
id: string;
name: string;

View file

@ -30,7 +30,7 @@
</div>
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :style="nodeIconStyle"/>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :style="nodeIconStyle" :shrink="true"/>
</div>
<div class="node-description">
<div class="node-name" :title="data.name">

View file

@ -1,85 +0,0 @@
<template>
<div class="node-item clickable" :class="{active: active}" :data-node-name="nodeName" @click="nodeTypeSelected(nodeType)">
<NodeIcon class="node-icon" :nodeType="nodeType" :style="nodeIconStyle" />
<div class="name">
{{nodeType.displayName}}
</div>
<div class="description">
{{nodeType.description}}
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { INodeTypeDescription } from 'n8n-workflow';
import NodeIcon from '@/components/NodeIcon.vue';
export default Vue.extend({
name: 'NodeCreateItem',
components: {
NodeIcon,
},
props: [
'active',
'filter',
'nodeType',
],
computed: {
nodeIconStyle (): object {
return {
color: this.nodeType.defaults.color,
};
},
nodeName (): string {
return this.nodeType.name;
},
},
methods: {
nodeTypeSelected (nodeType: INodeTypeDescription) {
this.$emit('nodeTypeSelected', nodeType.name);
},
},
});
</script>
<style scoped lang="scss">
.node-item {
position: relative;
border-bottom: 1px solid #eee;
background-color: #fff;
padding: 6px;
border-left: 3px solid #fff;
&:hover {
border-left: 3px solid #ccc;
}
}
.active {
border-left: 3px solid $--color-primary;
}
.node-icon {
display: inline-block;
position: absolute;
left: 12px;
top: calc(50% - 15px);
}
.name {
font-weight: bold;
font-size: 0.9em;
padding-left: 50px;
}
.description {
margin-top: 3px;
line-height: 1.7em;
font-size: 0.8em;
padding-left: 50px;
}
</style>

View file

@ -1,172 +0,0 @@
<template>
<div>
<div class="input-wrapper">
<el-input placeholder="Type to filter..." v-model="nodeFilter" ref="inputField" size="small" type="text" prefix-icon="el-icon-search" @keydown.native="nodeFilterKeyDown" clearable ></el-input>
</div>
<div class="type-selector">
<el-tabs v-model="selectedType" stretch>
<el-tab-pane label="Regular" name="Regular"></el-tab-pane>
<el-tab-pane label="Trigger" name="Trigger"></el-tab-pane>
<el-tab-pane label="All" name="All"></el-tab-pane>
</el-tabs>
</div>
<div class="node-create-list-wrapper">
<div class="node-create-list">
<div v-if="filteredNodeTypes.length === 0" class="no-results">
🙃 no nodes matching your search criteria
</div>
<node-create-item :active="index === activeNodeTypeIndex" :nodeType="nodeType" v-for="(nodeType, index) in filteredNodeTypes" v-bind:key="nodeType.name" @nodeTypeSelected="nodeTypeSelected"></node-create-item>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { externalHooks } from "@/components/mixins/externalHooks";
import { INodeTypeDescription } from 'n8n-workflow';
import NodeCreateItem from '@/components/NodeCreateItem.vue';
import mixins from "vue-typed-mixins";
export default mixins(externalHooks).extend({
name: 'NodeCreateList',
components: {
NodeCreateItem,
},
data () {
return {
activeNodeTypeIndex: 0,
nodeFilter: '',
selectedType: 'Regular',
};
},
computed: {
nodeTypes (): INodeTypeDescription[] {
return this.$store.getters.allNodeTypes;
},
filteredNodeTypes () {
const filter = this.nodeFilter.toLowerCase();
const nodeTypes: INodeTypeDescription[] = this.$store.getters.allNodeTypes;
// Apply the filters
const returnData = nodeTypes.filter((nodeType) => {
if (filter && nodeType.displayName.toLowerCase().indexOf(filter) === -1) {
return false;
}
if (this.selectedType !== 'All') {
if (this.selectedType === 'Trigger' && !nodeType.group.includes('trigger')) {
return false;
} else if (this.selectedType === 'Regular' && nodeType.group.includes('trigger')) {
return false;
}
}
return true;
});
// Sort the node types
let textA, textB;
returnData.sort((a, b) => {
textA = a.displayName.toLowerCase();
textB = b.displayName.toLowerCase();
return (textA < textB) ? -1 : (textA > textB) ? 1 : 0;
});
this.$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', { nodeFilter: this.nodeFilter, result: returnData, selectedType: this.selectedType });
return returnData;
},
},
watch: {
nodeFilter (newValue, oldValue) {
// Reset the index whenver the filter-value changes
this.activeNodeTypeIndex = 0;
this.$externalHooks().run('nodeCreateList.nodeFilterChanged', { oldValue, newValue, selectedType: this.selectedType, filteredNodes: this.filteredNodeTypes });
},
selectedType (newValue, oldValue) {
this.$externalHooks().run('nodeCreateList.selectedTypeChanged', { oldValue, newValue });
},
},
methods: {
nodeFilterKeyDown (e: KeyboardEvent) {
const activeNodeType = this.filteredNodeTypes[this.activeNodeTypeIndex];
if (e.key === 'ArrowDown') {
this.activeNodeTypeIndex++;
// Make sure that we stop at the last nodeType
this.activeNodeTypeIndex = Math.min(this.activeNodeTypeIndex, this.filteredNodeTypes.length - 1);
} else if (e.key === 'ArrowUp') {
this.activeNodeTypeIndex--;
// Make sure that we do not get before the first nodeType
this.activeNodeTypeIndex = Math.max(this.activeNodeTypeIndex, 0);
} else if (e.key === 'Enter' && activeNodeType) {
this.nodeTypeSelected(activeNodeType.name);
}
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();
}
},
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
},
async mounted() {
this.$externalHooks().run('nodeCreateList.mounted');
},
async destroyed() {
this.$externalHooks().run('nodeCreateList.destroyed');
},
});
</script>
<style scoped>
.node-create-list-wrapper {
position: absolute;
top: 160px;
left: 0px;
right: 0px;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
background-color: #fff;
}
.node-create-list {
position: relative;
width: 100%;
}
.group-name {
font-size: 0.9em;
padding: 15px 0 5px 10px;
}
.input-wrapper >>> .el-input__inner,
.input-wrapper >>> .el-input__inner:hover {
background-color: #fff;
}
.input-wrapper {
margin: 10px;
height: 35px;
}
.type-selector {
height: 50px;
text-align: center;
}
.type-selector >>> .el-tabs__nav {
padding-bottom: 10px;
}
.no-results {
margin: 20px 10px 0 10px;
line-height: 1.5em;
text-align: center;
}
</style>

View file

@ -1,104 +0,0 @@
<template>
<div class="node-creator-wrapper">
<transition name="el-zoom-in-top">
<div class="node-creator" v-show="active">
<div class="close-button clickable close-on-click" @click="closeCreator" title="Close">
<i class="el-icon-close close-on-click"></i>
</div>
<div class="header">
Create Node
</div>
<node-create-list v-if="active" ref="list" @nodeTypeSelected="nodeTypeSelected"></node-create-list>
</div>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import NodeCreateList from '@/components/NodeCreateList.vue';
export default Vue.extend({
name: 'NodeCreator',
components: {
NodeCreateList,
},
props: [
'active',
],
data () {
return {
};
},
watch: {
active (newValue, oldValue) {
if (newValue === true) {
// Try to set focus directly on the filter-input-field
setTimeout(() => {
// @ts-ignore
if (this.$refs.list && this.$refs.list.$refs.inputField) {
// @ts-ignore
this.$refs.list.$refs.inputField.focus();
}
});
}
},
},
methods: {
closeCreator () {
this.$emit('closeNodeCreator');
},
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
},
});
</script>
<style scoped lang="scss">
.close-button {
position: absolute;
top: 0;
left: -50px;
color: #fff;
background-color: $--custom-header-background;
border-radius: 18px 0 0 18px;
z-index: 110;
font-size: 1.7em;
text-align: center;
line-height: 50px;
height: 50px;
width: 50px;
.close-on-click {
color: #fff;
font-weight: 400;
&:hover {
transform: scale(1.2);
}
}
}
.node-creator {
position: fixed;
top: 65px;
right: 0;
width: 350px;
height: calc(100% - 65px);
background-color: #fff4f1;
z-index: 200;
color: #555;
.header {
font-size: 1.2em;
margin: 20px 15px;
height: 25px;
}
}
</style>

View file

@ -0,0 +1,42 @@
<template functional>
<div :class="$style.category">
<span :class="$style.name">{{ props.item.category }}</span>
<font-awesome-icon
:class="$style.arrow"
icon="chevron-down"
v-if="props.item.properties.expanded"
/>
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
</div>
</template>
<script lang="ts">
export default {
props: ['item'],
};
</script>
<style lang="scss" module>
.category {
font-size: 11px;
font-weight: bold;
letter-spacing: 1px;
line-height: 11px;
padding: 10px 0;
margin: 0 12px;
border-bottom: 1px solid $--node-creator-border-color;
display: flex;
text-transform: uppercase;
}
.name {
flex-grow: 1;
}
.arrow {
font-size: 12px;
width: 12px;
color: $--node-creator-arrow-color;
}
</style>

View file

@ -0,0 +1,57 @@
<template functional>
<div
:class="{
container: true,
clickable: props.clickable,
active: props.active,
}"
@click="listeners['click']"
>
<CategoryItem
v-if="props.item.type === 'category'"
:item="props.item"
/>
<SubcategoryItem
v-else-if="props.item.type === 'subcategory'"
:item="props.item"
/>
<NodeItem
v-else-if="props.item.type === 'node'"
:nodeType="props.item.properties.nodeType"
:bordered="!props.lastNode"
></NodeItem>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import NodeItem from './NodeItem.vue';
import CategoryItem from './CategoryItem.vue';
import SubcategoryItem from './SubcategoryItem.vue';
Vue.component('CategoryItem', CategoryItem);
Vue.component('SubcategoryItem', SubcategoryItem);
Vue.component('NodeItem', NodeItem);
export default {
props: ['item', 'active', 'clickable', 'lastNode'],
};
</script>
<style lang="scss" scoped>
.container {
position: relative;
border-left: 2px solid transparent;
&:hover {
border-color: $--node-creator-item-hover-border-color;
}
&.active {
border-color: $--color-primary !important;
}
}
</style>

View file

@ -0,0 +1,94 @@
<template>
<div>
<div
:is="transitionsEnabled ? 'transition-group' : 'div'"
name="accordion"
@before-enter="beforeEnter"
@enter="enter"
@before-leave="beforeLeave"
@leave="leave"
>
<div v-for="(item, index) in elements" :key="item.key" :class="item.type">
<CreatorItem
:item="item"
:active="activeIndex === index && !disabled"
:clickable="!disabled"
:lastNode="
index === elements.length - 1 || elements[index + 1].type !== 'node'
"
@click="() => selected(item)"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import { INodeCreateElement } from '@/Interface';
import Vue from 'vue';
import CreatorItem from './CreatorItem.vue';
export default Vue.extend({
name: 'ItemIterator',
components: {
CreatorItem,
},
props: ['elements', 'activeIndex', 'disabled', 'transitionsEnabled'],
methods: {
selected(element: INodeCreateElement) {
if (this.$props.disabled) {
return;
}
this.$emit('selected', element);
},
beforeEnter(el: HTMLElement) {
el.style.height = '0';
},
enter(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
},
beforeLeave(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
},
leave(el: HTMLElement) {
el.style.height = '0';
},
},
});
</script>
<style lang="scss" scoped>
.accordion-enter {
opacity: 0;
}
.accordion-leave-active {
opacity: 1;
}
.accordion-leave-active {
transition: all 0.25s ease, opacity 0.1s ease;
margin-top: 0;
}
.accordion-enter-active {
transition: all 0.25s ease, opacity 0.25s ease;
margin-top: 0;
}
.accordion-leave-to {
opacity: 0;
}
.accordion-enter-to {
opacity: 1;
}
.subcategory + .category,
.node + .category {
margin-top: 15px;
}
</style>

View file

@ -0,0 +1,332 @@
<template>
<div @click="onClickInside" class="container">
<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="All" :name="ALL_NODE_FILTER"></el-tab-pane>
<el-tab-pane label="Regular" :name="REGULAR_NODE_FILTER"></el-tab-pane>
<el-tab-pane label="Trigger" :name="TRIGGER_NODE_FILTER"></el-tab-pane>
</el-tabs>
</div>
<div v-if="searchFilter.length === 0" class="scrollable">
<ItemIterator
:elements="categorized"
:disabled="!!activeSubcategory"
:activeIndex="activeIndex"
:transitionsEnabled="true"
@selected="selected"
/>
</div>
<div
class="scrollable"
v-else-if="filteredNodeTypes.length > 0"
>
<ItemIterator
:elements="filteredNodeTypes"
:activeIndex="activeIndex"
@selected="selected"
/>
</div>
<NoResults v-else @nodeTypeSelected="nodeTypeSelected" />
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { externalHooks } from '@/components/mixins/externalHooks';
import mixins from 'vue-typed-mixins';
import ItemIterator from './ItemIterator.vue';
import NoResults from './NoResults.vue';
import SearchBar from './SearchBar.vue';
import SubcategoryPanel from './SubcategoryPanel.vue';
import { INodeCreateElement, INodeItemProps, ISubcategoryItemProps } from '@/Interface';
import { ALL_NODE_FILTER, CORE_NODES_CATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER } from '@/constants';
import SlideTransition from '../transitions/SlideTransition.vue';
import { matchesNodeType, matchesSelectType } from './helpers';
export default mixins(externalHooks).extend({
name: 'NodeCreateList',
components: {
ItemIterator,
NoResults,
SubcategoryPanel,
SlideTransition,
SearchBar,
},
props: ['categorizedItems', 'categoriesWithNodes', 'searchItems'],
data() {
return {
activeCategory: [] as string[],
activeSubcategory: null as INodeCreateElement | null,
activeIndex: 1,
activeSubcategoryIndex: 0,
nodeFilter: '',
selectedType: ALL_NODE_FILTER,
searchEventBus: new Vue(),
REGULAR_NODE_FILTER,
TRIGGER_NODE_FILTER,
ALL_NODE_FILTER,
};
},
computed: {
searchFilter(): string {
return this.nodeFilter.toLowerCase().trim();
},
filteredNodeTypes(): INodeCreateElement[] {
const nodeTypes: INodeCreateElement[] = this.searchItems;
const filter = this.searchFilter;
const returnData = nodeTypes.filter((el: INodeCreateElement) => {
const nodeType = (el.properties as INodeItemProps).nodeType;
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);
});
setTimeout(() => {
this.$externalHooks().run('nodeCreateList.filteredNodeTypesComputed', {
nodeFilter: this.nodeFilter,
result: returnData,
selectedType: this.selectedType,
});
}, 0);
return returnData;
},
categorized() {
return this.categorizedItems && this.categorizedItems
.reduce((accu: INodeCreateElement[], el: INodeCreateElement) => {
if (
el.type !== 'category' &&
!this.activeCategory.includes(el.category)
) {
return accu;
}
if (!matchesSelectType(el, this.selectedType)) {
return accu;
}
if (el.type === 'category') {
accu.push({
...el,
properties: {
expanded: this.activeCategory.includes(el.category),
},
} as INodeCreateElement);
return accu;
}
accu.push(el);
return accu;
}, []);
},
subcategorizedNodes() {
const activeSubcategory = this.activeSubcategory as INodeCreateElement;
const category = activeSubcategory.category;
const subcategory = (activeSubcategory.properties as ISubcategoryItemProps).subcategory;
return activeSubcategory && this.categoriesWithNodes[category][subcategory]
.nodes.filter((el: INodeCreateElement) => matchesSelectType(el, this.selectedType));
},
},
watch: {
nodeFilter(newValue, oldValue) {
// Reset the index whenver the filter-value changes
this.activeIndex = 0;
this.$externalHooks().run('nodeCreateList.nodeFilterChanged', {
oldValue,
newValue,
selectedType: this.selectedType,
filteredNodes: this.filteredNodeTypes,
});
},
selectedType(newValue, oldValue) {
this.$externalHooks().run('nodeCreateList.selectedTypeChanged', {
oldValue,
newValue,
});
},
},
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.type === 'subcategory') {
this.selected(activeNodeType);
} else if (e.key === 'ArrowRight' && activeNodeType.type === 'category' && !activeNodeType.properties.expanded) {
this.selected(activeNodeType);
} else if (e.key === 'ArrowLeft' && activeNodeType.type === 'category' && activeNodeType.properties.expanded) {
this.selected(activeNodeType);
}
},
selected(element: INodeCreateElement) {
if (element.type === 'node') {
const properties = element.properties as INodeItemProps;
this.nodeTypeSelected(properties.nodeType.name);
} else if (element.type === 'category') {
this.onCategorySelected(element.category);
} else if (element.type === 'subcategory') {
this.onSubcategorySelected(element);
}
},
nodeTypeSelected(nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
onCategorySelected(category: string) {
if (this.activeCategory.includes(category)) {
this.activeCategory = this.activeCategory.filter(
(active: string) => active !== category,
);
} else {
this.activeCategory = [...this.activeCategory, category];
}
this.activeIndex = this.categorized.findIndex(
(el: INodeCreateElement) => el.category === category,
);
},
onSubcategorySelected(selected: INodeCreateElement) {
this.activeSubcategoryIndex = 0;
this.activeSubcategory = selected;
},
onSubcategoryClose() {
this.activeSubcategory = null;
this.activeSubcategoryIndex = 0;
this.nodeFilter = '';
},
onClickInside() {
this.searchEventBus.$emit('focus');
},
},
async mounted() {
this.$nextTick(() => {
// initial opening effect
this.activeCategory = [CORE_NODES_CATEGORY];
});
this.$externalHooks().run('nodeCreateList.mounted');
},
async destroyed() {
this.$externalHooks().run('nodeCreateList.destroyed');
},
});
</script>
<style lang="scss" scoped>
/deep/ .el-tabs__item {
padding: 0;
}
/deep/ .el-tabs__active-bar {
height: 1px;
}
/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;
/deep/ .el-tabs > div {
margin-bottom: 0;
.el-tabs__nav {
height: 43px;
}
}
}
</style>

View file

@ -0,0 +1,126 @@
<template>
<div class="no-results">
<div class="icon">
<NoResultsIcon />
</div>
<div class="title">
<div>We didn't make that... yet</div>
<div class="action">
Dont worry, you can probably do it with the
<a @click="selectHttpRequest">HTTP Request</a> or
<a @click="selectWebhook">Webhook</a> node
</div>
</div>
<div class="request">
<div>Want us to make it faster?</div>
<div>
<a
:href="REQUEST_NODE_FORM_URL"
target="_blank"
>
<span>Request the node</span>&nbsp;
<span>
<font-awesome-icon
class="external"
icon="external-link-alt"
title="Request the node"
/>
</span>
</a>
</div>
</div>
</div>
</template>
<script lang="ts">
import { HTTP_REQUEST_NODE_NAME, REQUEST_NODE_FORM_URL, WEBHOOK_NODE_NAME } from '@/constants';
import Vue from 'vue';
import NoResultsIcon from './NoResultsIcon.vue';
export default Vue.extend({
name: 'NoResults',
components: {
NoResultsIcon,
},
data() {
return {
REQUEST_NODE_FORM_URL,
};
},
computed: {
basePath(): string {
return this.$store.getters.getBaseUrl;
},
},
methods: {
selectWebhook() {
this.$emit('nodeTypeSelected', WEBHOOK_NODE_NAME);
},
selectHttpRequest() {
this.$emit('nodeTypeSelected', HTTP_REQUEST_NODE_NAME);
},
},
});
</script>
<style lang="scss" scoped>
.no-results {
background-color: $--node-creator-no-results-background-color;
text-align: center;
height: 100%;
border-left: 1px solid $--node-creator-border-color;
flex-direction: column;
font-weight: 400;
display: flex;
align-items: center;
align-content: center;
padding: 0 50px;
}
.title {
font-size: 22px;
line-height: 22px;
margin-top: 50px;
div {
margin-bottom: 15px;
}
}
.action, .request {
font-size: 14px;
line-height: 19px;
}
.request {
position: fixed;
bottom: 20px;
display: none;
@media (min-height: 550px) {
display: block;
}
}
a {
color: $--color-primary;
text-decoration: none;
cursor: pointer;
font-weight: 500;
}
.icon {
margin-top: 100px;
min-height: 67px;
opacity: .6;
}
.external {
font-size: 12px;
}
</style>

View file

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

View file

@ -0,0 +1,101 @@
<template>
<div>
<SlideTransition>
<div class="node-creator" v-if="active" v-click-outside="closeCreator">
<MainPanel @nodeTypeSelected="nodeTypeSelected" :categorizedItems="categorizedItems" :categoriesWithNodes="categoriesWithNodes" :searchItems="searchItems"></MainPanel>
</div>
</SlideTransition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { ICategoriesWithNodes, INodeCreateElement } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import SlideTransition from '../transitions/SlideTransition.vue';
import { HIDDEN_NODES } from '@/constants';
import MainPanel from './MainPanel.vue';
import { getCategoriesWithNodes, getCategorizedList } from './helpers';
export default Vue.extend({
name: 'NodeCreator',
components: {
MainPanel,
SlideTransition,
},
props: [
'active',
],
computed: {
visibleNodeTypes(): INodeTypeDescription[] {
return this.$store.getters.allNodeTypes
.filter((nodeType: INodeTypeDescription) => {
return !HIDDEN_NODES.includes(nodeType.name);
});
},
categoriesWithNodes(): ICategoriesWithNodes {
return getCategoriesWithNodes(this.visibleNodeTypes);
},
categorizedItems(): INodeCreateElement[] {
return getCategorizedList(this.categoriesWithNodes);
},
searchItems(): INodeCreateElement[] {
const sorted = [...this.visibleNodeTypes];
sorted.sort((a, b) => {
const textA = a.displayName.toLowerCase();
const textB = b.displayName.toLowerCase();
return textA < textB ? -1 : textA > textB ? 1 : 0;
});
return sorted.map((nodeType) => ({
type: 'node',
category: '',
key: `${nodeType.name}`,
properties: {
nodeType,
subcategory: '',
},
includedByTrigger: nodeType.group.includes('trigger'),
includedByRegular: !nodeType.group.includes('trigger'),
}));
},
},
methods: {
closeCreator () {
this.$emit('closeNodeCreator');
},
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
},
});
</script>
<style scoped lang="scss">
/deep/ *, *:before, *:after {
box-sizing: border-box;
}
.node-creator {
position: fixed;
top: $--header-height;
right: 0;
width: $--node-creator-width;
height: 100%;
background-color: $--node-creator-background-color;
z-index: 200;
color: $--node-creator-text-color;
&:before {
box-sizing: border-box;
content: ' ';
border-left: 1px solid $--node-creator-border-color;
width: 1px;
position: absolute;
height: 100%;
}
}
</style>

View file

@ -0,0 +1,86 @@
<template functional>
<div :class="{[$style['node-item']]: true, [$style.bordered]: props.bordered}">
<NodeIcon :class="$style['node-icon']" :nodeType="props.nodeType" :style="{color: props.nodeType.defaults.color}" />
<div>
<div :class="$style.details">
<span :class="$style.name">{{props.nodeType.displayName}}</span>
<span :class="$style['trigger-icon']">
<TriggerIcon v-if="$options.isTrigger(props.nodeType)" />
</span>
</div>
<div :class="$style.description">
{{props.nodeType.description}}
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { INodeTypeDescription } from 'n8n-workflow';
import NodeIcon from '../NodeIcon.vue';
import TriggerIcon from '../TriggerIcon.vue';
Vue.component('NodeIcon', NodeIcon);
Vue.component('TriggerIcon', TriggerIcon);
export default {
props: [
'active',
'filter',
'nodeType',
'bordered',
],
isTrigger (nodeType: INodeTypeDescription): boolean {
return nodeType.group.includes('trigger');
},
};
</script>
<style lang="scss" module>
.node-item {
padding: 11px 8px 11px 0;
margin-left: 15px;
margin-right: 12px;
display: flex;
&.bordered {
border-bottom: 1px solid $--node-creator-border-color;
}
}
.details {
display: flex;
align-items: center;
}
.node-icon {
min-width: 26px;
max-width: 26px;
margin-right: 15px;
}
.name {
font-weight: bold;
font-size: 14px;
line-height: 18px;
margin-right: 5px;
}
.description {
margin-top: 2px;
font-size: 11px;
line-height: 16px;
font-weight: 400;
color: $--node-creator-description-color;
}
.trigger-icon {
height: 16px;
width: 16px;
display: flex;
}
</style>

View file

@ -0,0 +1,124 @@
<template>
<div class="search-container">
<div :class="{ prefix: true, active: value.length > 0 }">
<font-awesome-icon icon="search" />
</div>
<div class="text">
<input
placeholder="Search nodes..."
ref="input"
:value="value"
@input="onInput"
/>
</div>
<div class="suffix" v-if="value.length > 0" @click="clear">
<span class="clear el-icon-close clickable"></span>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
name: "SearchBar",
props: ["value", "eventBus"],
mounted() {
if (this.$props.eventBus) {
this.$props.eventBus.$on("focus", () => {
this.focus();
});
}
setTimeout(() => {
this.focus();
}, 0);
},
methods: {
focus() {
const input = this.$refs.input as HTMLInputElement;
if (input) {
input.focus();
}
},
onInput(event: InputEvent) {
const input = event.target as HTMLInputElement;
this.$emit("input", input.value);
},
clear() {
this.$emit("input", "");
},
},
});
</script>
<style lang="scss" scoped>
.search-container {
display: flex;
height: 60px;
align-items: center;
padding-left: 14px;
padding-right: 20px;
border-top: 1px solid $--node-creator-border-color;
border-bottom: 1px solid $--node-creator-border-color;
background-color: $--node-creator-search-background-color;
color: $--node-creator-search-placeholder-color;
}
.prefix {
text-align: center;
font-size: 16px;
margin-right: 14px;
&.active {
color: $--color-primary !important;
}
}
.text {
flex-grow: 1;
input {
width: 100%;
border: none !important;
outline: none;
font-size: 18px;
-webkit-appearance: none;
&::placeholder,
&::-webkit-input-placeholder {
color: $--node-creator-search-placeholder-color;
}
}
}
.suffix {
min-width: 20px;
text-align: center;
display: inline-block;
}
.clear {
background-color: $--node-creator-search-clear-background-color;
border-radius: 50%;
height: 16px;
width: 16px;
font-size: 16px;
color: $--node-creator-search-background-color;
display: inline-flex;
align-items: center;
&:hover {
background-color: $--node-creator-search-clear-background-color-hover;
}
&:before {
line-height: 16px;
display: flex;
height: 16px;
width: 16px;
font-size: 15px;
align-items: center;
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,58 @@
<template functional>
<div :class="$style.subcategory">
<div :class="$style.details">
<div :class="$style.title">{{ props.item.properties.subcategory }}</div>
<div v-if="props.item.properties.description" :class="$style.description">
{{ props.item.properties.description }}
</div>
</div>
<div :class="$style.action">
<font-awesome-icon :class="$style.arrow" icon="arrow-right" />
</div>
</div>
</template>
<script lang="ts">
export default {
props: ['item'],
};
</script>
<style lang="scss" module>
.subcategory {
display: flex;
padding: 11px 16px 11px 30px;
}
.details {
flex-grow: 1;
margin-right: 4px;
}
.title {
font-size: 14px;
font-weight: bold;
line-height: 16px;
margin-bottom: 3px;
}
.description {
font-size: 11px;
line-height: 16px;
font-weight: 400;
color: $--node-creator-description-color;
}
.action {
display: flex;
align-items: center;
}
.arrow {
font-size: 12px;
width: 12px;
color: $--node-creator-arrow-color;
}
</style>

View file

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

View file

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

View file

@ -1,7 +1,7 @@
<template>
<div class="node-icon-wrapper" :style="iconStyleData" :class="{full: isSvgIcon}">
<div class="node-icon-wrapper" :style="iconStyleData" :class="{shrink: isSvgIcon && shrink, full: !shrink}">
<div v-if="nodeIconData !== null" class="icon">
<img :src="nodeIconData.path" style="width: 100%; height: 100%;" v-if="nodeIconData.type === 'file'"/>
<img :src="nodeIconData.path" style="max-width: 100%; max-height: 100%;" v-if="nodeIconData.type === 'file'"/>
<font-awesome-icon :icon="nodeIconData.path" v-else-if="nodeIconData.type === 'fa'" />
</div>
<div v-else class="node-icon-placeholder">
@ -25,6 +25,7 @@ export default Vue.extend({
props: [
'nodeType',
'size',
'shrink',
],
computed: {
iconStyleData (): object {
@ -79,19 +80,23 @@ export default Vue.extend({
<style lang="scss">
.node-icon-wrapper {
width: 30px;
height: 30px;
border-radius: 15px;
width: 26px;
height: 26px;
border-radius: 4px;
color: #444;
line-height: 30px;
line-height: 26px;
font-size: 1.1em;
overflow: hidden;
background-color: #fff;
text-align: center;
font-weight: bold;
font-size: 20px;
&.full .icon {
height: 100%;
width: 100%;
}
&.shrink .icon {
margin: 0.24em;
}

View file

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

View file

@ -0,0 +1,24 @@
<template>
<transition name="slide">
<slot></slot>
</transition>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'SlideTransition',
});
</script>
<style lang="scss" scoped>
.slide-leave-active,
.slide-enter-active {
transition: 0.3s ease;
}
.slide-leave-to,
.slide-enter {
transform: translateX(100%);
}
</style>

View file

@ -15,8 +15,31 @@ export const DUPLICATE_MODAL_KEY = 'duplicate';
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const WORKLOW_OPEN_MODAL_KEY = 'workflowOpen';
// breakpoints
export const BREAKPOINT_SM = 768;
export const BREAKPOINT_MD = 992;
export const BREAKPOINT_LG = 1200;
export const BREAKPOINT_XL = 1920;
// Node creator
export const CORE_NODES_CATEGORY = 'Core Nodes';
export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
export const SUBCATEGORY_DESCRIPTIONS: {
[category: string]: { [subcategory: string]: string };
} = {
'Core Nodes': {
Flow: 'Branches, core triggers, merge data',
Files: 'Work with CSV, XML, text, images etc.',
'Data Transformation': 'Manipulate data fields, run code',
Helpers: 'HTTP Requests (API calls), date and time, scrape HTML',
},
};
export const REGULAR_NODE_FILTER = 'Regular';
export const TRIGGER_NODE_FILTER = 'Trigger';
export const ALL_NODE_FILTER = 'All';
export const UNCATEGORIZED_CATEGORY = 'Miscellaneous';
export const UNCATEGORIZED_SUBCATEGORY = 'Helpers';
export const HIDDEN_NODES = ['n8n-nodes-base.start'];
export const WEBHOOK_NODE_NAME = 'n8n-nodes-base.webhook';
export const HTTP_REQUEST_NODE_NAME = 'n8n-nodes-base.httpRequest';
export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3';

View file

@ -28,12 +28,15 @@ import {
faAngleDown,
faAngleRight,
faAngleUp,
faArrowLeft,
faArrowRight,
faAt,
faBook,
faBug,
faCalendar,
faCheck,
faChevronDown,
faChevronUp,
faCode,
faCodeBranch,
faCog,
@ -79,6 +82,7 @@ import {
faRedo,
faRss,
faSave,
faSearch,
faSearchMinus,
faSearchPlus,
faServer,
@ -112,12 +116,15 @@ library.add(faAngleDoubleLeft);
library.add(faAngleDown);
library.add(faAngleRight);
library.add(faAngleUp);
library.add(faArrowLeft);
library.add(faArrowRight);
library.add(faAt);
library.add(faBook);
library.add(faBug);
library.add(faCalendar);
library.add(faCheck);
library.add(faChevronDown);
library.add(faChevronUp);
library.add(faCode);
library.add(faCodeBranch);
library.add(faCog);
@ -163,6 +170,7 @@ library.add(faQuestionCircle);
library.add(faRedo);
library.add(faRss);
library.add(faSave);
library.add(faSearch);
library.add(faSearchMinus);
library.add(faSearchPlus);
library.add(faServer);

View file

@ -63,3 +63,23 @@ $--tag-text-color: #3d3f46;
$--tag-close-background-color: #717782;
$--tag-close-background-hover-color: #3d3f46;
// Node creator
$--node-creator-width: 385px;
$--node-creator-text-color: #555;
$--node-creator-select-background-color: #f2f4f8;
$--node-creator-background-color: #fff;
$--node-creator-search-background-color: #fff;
$--node-creator-border-color: #dbdfe7;
$--node-creator-item-hover-border-color: #8d939c;
$--node-creator-arrow-color: #8d939c;
$--node-creator-no-results-background-color: #f8f9fb;
$--node-creator-close-button-color: #fff;
$--node-creator-search-clear-background-color: #8d939c;
$--node-creator-search-clear-background-color-hover: #3d3f46;
$--node-creator-search-placeholder-color: #909399;
$--node-creator-subcategory-panel-header-bacground-color: #f2f4f8;
$--node-creator-description-color: #7d7d87;
// trigger icon
$--trigger-icon-border-color: #dcdfe6;
$--trigger-icon-background-color: #fff;

View file

@ -129,7 +129,7 @@ import { workflowRun } from '@/components/mixins/workflowRun';
import DataDisplay from '@/components/DataDisplay.vue';
import Modals from '@/components/Modals.vue';
import Node from '@/components/Node.vue';
import NodeCreator from '@/components/NodeCreator.vue';
import NodeCreator from '@/components/NodeCreator/NodeCreator.vue';
import NodeSettings from '@/components/NodeSettings.vue';
import RunData from '@/components/RunData.vue';

View file

@ -118,7 +118,12 @@ export async function buildFiles (options?: IBuildOptions): Promise<string> {
}
return new Promise((resolve, reject) => {
copyfiles([join(process.cwd(), './*.png'), outputDirectory], { up: true }, () => resolve(outputDirectory));
['*.png', '*.node.json'].forEach(filenamePattern => {
copyfiles(
[join(process.cwd(), `./${filenamePattern}`), outputDirectory],
{ up: true },
() => resolve(outputDirectory));
});
buildProcess.on('exit', code => {
// Remove the tmp tsconfig file
tsconfigData.cleanup();

View file

@ -567,6 +567,7 @@ export interface INodeTypeDescription {
deactivate?: INodeHookDescription[];
};
webhooks?: IWebhookDescription[];
codex?: CodexData;
}
export interface INodeHookDescription {
@ -781,6 +782,12 @@ export interface IStatusCodeMessages {
[key: string]: string;
}
export type CodexData = {
categories?: string[];
subcategories?: {[category: string]: string[]};
alias?: string[];
};
export type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
export type JsonObject = { [key: string]: JsonValue };