mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-23 11:44:06 -08:00
✨ 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:
parent
6e68d71f4d
commit
0470740737
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
332
packages/editor-ui/src/components/NodeCreator/MainPanel.vue
Normal file
332
packages/editor-ui/src/components/NodeCreator/MainPanel.vue
Normal 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>
|
126
packages/editor-ui/src/components/NodeCreator/NoResults.vue
Normal file
126
packages/editor-ui/src/components/NodeCreator/NoResults.vue
Normal 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">
|
||||
Don’t 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>
|
||||
<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>
|
|
@ -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>
|
101
packages/editor-ui/src/components/NodeCreator/NodeCreator.vue
Normal file
101
packages/editor-ui/src/components/NodeCreator/NodeCreator.vue
Normal 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>
|
86
packages/editor-ui/src/components/NodeCreator/NodeItem.vue
Normal file
86
packages/editor-ui/src/components/NodeCreator/NodeItem.vue
Normal 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>
|
124
packages/editor-ui/src/components/NodeCreator/SearchBar.vue
Normal file
124
packages/editor-ui/src/components/NodeCreator/SearchBar.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
176
packages/editor-ui/src/components/NodeCreator/helpers.ts
Normal file
176
packages/editor-ui/src/components/NodeCreator/helpers.ts
Normal 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);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
42
packages/editor-ui/src/components/TriggerIcon.vue
Normal file
42
packages/editor-ui/src/components/TriggerIcon.vue
Normal 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>
|
|
@ -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>
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 };
|
||||
|
|
Loading…
Reference in a new issue