feat(editor): Node creator actions (#4696)

* WIP: Node Actions List UI

* WIP: Recommended Actions and preseting of fields

* WIP: Resource category

* 🎨 Moved actions categorisation to the server

* 🏷️ Add missing INodeAction type

*  Improve SSR categorisation, fix adding of mixed actions

* ♻️ Refactor CategorizedItems to composition api, style fixes

* WIP: Adding multiple nodes

* ♻️ Refactor rest of the NodeCreator component to composition API, conver globalLinkActions to composable

*  Allow actions dragging, fix search and refactor passing of actions to categorized items

* 💄 Fix node actions title

* Migrate to the pinia store, add posthog feature and various fixes

* 🐛 Fix filtering of trigger actions when not merged

* fix: N8N-5439 — Do not use simple node item when at NodeHelperPanel root

* 🐛 Design review fixes

* 🐛 Fix disabling of merged actions

* Fix trigger root filtering

*  Allow for custom node actions parser, introduce hubspot parser

* 🐛 Fix initial node params validation, fix position of second added node

* 🐛 Introduce operations category, removed canvas node names overrride, fix API actions display and prevent dragging of action nodes

*  Prevent NDV auto-open feature flag

* 🐛 Inject recommened action for trigger nodes without actions

* Refactored NodeCreatorNode to Storybook, change filtering of merged nodes for the trigger helper panel, minor fixes

* Improve rendering of app nodes and animation

* Cleanup, any only enable accordion transition on triggerhelperpanel

* Hide node creator scrollbars in Firefox

* Minor styles fixes

* Do not copy the array in rendering method

* Removed unused props

* Fix memory leak

* Fix categorisation of regular nodes with a single resource

* Implement telemetry calls for node actions

* Move categorization to FE

* Fix client side actions categorisation

* Skip custom action show

* Only load tooltip for NodeIcon if necessary

* Fix lodash startCase import

* Remove lodash.startcase

* Cleanup

* Fix node creator autofocus on "tab"

* Prevent posthog getFeatureFlag from crashing

* Debugging preview env search issues

* Remove logs

* Make sure the pre-filled params are update not overwritten

* Get rid of transition in itemiterator

* WIP: Rough version of NodeActions keyboard navigation, replace nodeCreator composable with Pinia store module

* Rewrite to add support for ActionItem to ItemIterator and make CategorizedItems accept items props

* Fix category item counter & cleanup

* Add APIHint to actions search no-result, clean up NodeCreatorNode

* Improve node actions no results message

* Remove logging, fix filtering of recommended placeholder category

* Remove unused NodeActions component and node merging feature falg

* Do not show regular nodes without actions

* Make sure to add manual trigger when adding http node via actions hint

* Fixed api hint footer line height

* Prevent pointer-events od NodeIcon img and remove "this" from template

* Address PR points

* Fix e2e specs

* Make sure canvas ia loaded

* Make sure canvas ia loaded before opening nodeCreator in e2e spec

* Fix flaky workflows tags e2e getter

* Imrpove node creator click outside UX, add manual node to regular nodes added from trigger panel

* Add manual trigger node if dragging regular from trigger panel
This commit is contained in:
OlegIvaniv
2022-12-09 10:56:36 +01:00
committed by GitHub
parent b7c1359090
commit 79fe57dad8
78 changed files with 2498 additions and 1515 deletions

View File

@@ -84,16 +84,16 @@ export default Vue.extend({
position[0] -= DEFAULT_STICKY_WIDTH / 2;
position[1] -= DEFAULT_STICKY_HEIGHT / 2;
this.$emit('addNode', {
this.$emit('addNode', [{
nodeTypeName: STICKY_NODE_TYPE,
position,
});
}]);
},
closeNodeCreator() {
this.$emit('toggleNodeCreator', { createNodeActive: false });
},
nodeTypeSelected(nodeTypeName: string) {
this.$emit('addNode', { nodeTypeName });
nodeTypeSelected(nodeTypeNames: string[]) {
this.$emit('addNode', nodeTypeNames.map(nodeTypeName => ({ nodeTypeName })));
this.closeNodeCreator();
},
},

View File

@@ -0,0 +1,162 @@
<template>
<n8n-node-creator-node
:key="`${action.actionKey}_${action.displayName}`"
@click="onActionClick(action)"
@dragstart="onDragStart"
@dragend="onDragEnd"
draggable
:class="$style.action"
:title="action.displayName"
:isTrigger="isTriggerAction(action)"
>
<template #dragContent>
<div :class="$style.draggableDataTransfer" ref="draggableDataTransfer"/>
<div
:class="$style.draggable"
:style="draggableStyle"
v-show="dragging"
>
<node-icon :nodeType="nodeType" @click.capture.stop :size="40" :shrink="false" />
</div>
</template>
<template #icon>
<node-icon :nodeType="action" />
</template>
</n8n-node-creator-node>
</template>
<script setup lang="ts">
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
import { INodeTypeDescription, INodeActionTypeDescription } from 'n8n-workflow';
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
import { IUpdateInformation } from '@/Interface';
import NodeIcon from '@/components/NodeIcon.vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
export interface Props {
nodeType: INodeTypeDescription,
action: INodeActionTypeDescription,
}
const props = defineProps<Props>();
const instance = getCurrentInstance();
const telemetry = instance?.proxy.$telemetry;
const { getActionData, getNodeTypesWithManualTrigger, setAddedNodeActionParameters } = useNodeCreatorStore();
const state = reactive({
dragging: false,
draggablePosition: {
x: -100,
y: -100,
},
storeWatcher: null as Function | null,
draggableDataTransfer: null as Element | null,
});
const emit = defineEmits<{
(event: 'actionSelected', action: IUpdateInformation): void,
(event: 'dragstart', $e: DragEvent): void,
(event: 'dragend', $e: DragEvent): void,
}>();
const draggableStyle = computed<{ top: string; left: string; }>(() => ({
top: `${state.draggablePosition.y}px`,
left: `${state.draggablePosition.x}px`,
}));
const actionData = computed(() => getActionData(props.action));
const isTriggerAction = (action: INodeActionTypeDescription) => action.name?.toLowerCase().includes('trigger');
function onActionClick(actionItem: INodeActionTypeDescription) {
emit('actionSelected', getActionData(actionItem));
}
function onDragStart(event: DragEvent): void {
/**
* Workaround for firefox, that doesn't attach the pageX and pageY coordinates to "ondrag" event.
* All browsers attach the correct page coordinates to the "dragover" event.
* @bug https://bugzilla.mozilla.org/show_bug.cgi?id=505521
*/
document.body.addEventListener("dragover", onDragOver);
const { pageX: x, pageY: y } = event;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.dropEffect = "copy";
event.dataTransfer.setDragImage(state.draggableDataTransfer as Element, 0, 0);
event.dataTransfer.setData('nodeTypeName', getNodeTypesWithManualTrigger(actionData.value?.key).join(','));
state.storeWatcher = setAddedNodeActionParameters(actionData.value, telemetry);
document.body.addEventListener("dragend", onDragEnd);
}
state.dragging = true;
state.draggablePosition = { x, y };
emit('dragstart', event);
}
function onDragOver(event: DragEvent): void {
if (!state.dragging || event.pageX === 0 && event.pageY === 0) {
return;
}
const [x,y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
state.draggablePosition = { x, y };
}
function onDragEnd(event: DragEvent): void {
if(state.storeWatcher) state.storeWatcher();
document.body.removeEventListener("dragend", onDragEnd);
document.body.removeEventListener("dragover", onDragOver);
emit('dragend', event);
state.dragging = false;
setTimeout(() => {
state.draggablePosition = { x: -100, y: -100 };
}, 300);
}
const { draggableDataTransfer, dragging } = toRefs(state);
</script>
<style lang="scss" module>
.action {
margin-left: 15px;
margin-right: 12px;
--trigger-icon-background-color: #{$trigger-icon-background-color};
--trigger-icon-border-color: #{$trigger-icon-border-color};
}
.nodeIcon {
margin-right: var(--spacing-s);
}
.apiHint {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
padding-top: var(--spacing-s);
line-height: var(--font-line-height-regular);
border-top: 1px solid #DBDFE7;
z-index: 1;
// Prevent double borders when the last category is collapsed
margin-top: -1px;
}
.draggable {
width: 100px;
height: 100px;
position: fixed;
z-index: 1;
opacity: 0.66;
border: 2px solid var(--color-foreground-xdark);
border-radius: var(--border-radius-large);
background-color: var(--color-background-xlight);
display: flex;
justify-content: center;
align-items: center;
}
.draggableDataTransfer {
width: 1px;
height: 1px;
}
</style>

View File

@@ -1,88 +1,50 @@
<template>
<div :class="$style.category">
<span :class="$style.name">
{{ renderCategoryName(categoryName) }} ({{ nodesCount }})
{{ renderCategoryName(item.category) }}{{ count !== undefined ? ` (${count})` : ''}}
</span>
<font-awesome-icon
:class="$style.arrow"
v-if="isExpanded"
icon="chevron-down"
v-if="item.properties.expanded"
:class="$style.arrow"
/>
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
<script lang="ts" setup>
import { computed, getCurrentInstance } from 'vue';
import camelcase from 'lodash.camelcase';
import { CategoryName } from '@/plugins/i18n';
import { INodeCreateElement, ICategoriesWithNodes } from '@/Interface';
import { NODE_TYPE_COUNT_MAPPER } from '@/constants';
import { mapStores } from 'pinia';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { INodeCreateElement, ICategoryItemProps } from '@/Interface';
export interface Props {
item: INodeCreateElement;
count?: number;
}
const props = defineProps<Props>();
const instance = getCurrentInstance();
export default Vue.extend({
props: {
item: {
type: Object as PropType<INodeCreateElement>,
},
},
computed: {
...mapStores(
useNodeCreatorStore,
useNodeTypesStore,
),
selectedType(): "Regular" | "Trigger" | "All" {
return this.nodeCreatorStore.selectedType;
},
categoriesWithNodes(): ICategoriesWithNodes {
return this.nodeTypesStore.categoriesWithNodes;
},
categorizedItems(): INodeCreateElement[] {
return this.nodeTypesStore.categorizedItems;
},
categoryName() {
return camelcase(this.item.category);
},
nodesCount(): number {
const currentCategory= (this.categoriesWithNodes as ICategoriesWithNodes)[this.item.category];
const subcategories = Object.keys(currentCategory);
const isExpanded = computed<boolean>(() =>(props.item.properties as ICategoryItemProps).expanded);
// We need to sum subcategories count for the curent nodeType view
// to get the total count of category
const count = subcategories.reduce((accu: number, subcategory: string) => {
const countKeys = NODE_TYPE_COUNT_MAPPER[this.selectedType];
function renderCategoryName(categoryName: string) {
const camelCasedCategoryName = camelcase(categoryName) as CategoryName;
const key = `nodeCreator.categoryNames.${camelCasedCategoryName}` as const;
for (const countKey of countKeys) {
accu += currentCategory[subcategory][(countKey as "triggerCount" | "regularCount")];
}
return accu;
}, 0);
return count;
},
},
methods: {
renderCategoryName(categoryName: CategoryName) {
const key = `nodeCreator.categoryNames.${categoryName}` as const;
return this.$locale.exists(key) ? this.$locale.baseText(key) : this.item.category;
},
},
});
return instance?.proxy.$locale.exists(key)
? instance?.proxy.$locale.baseText(key)
: categoryName;
}
</script>
<style lang="scss" module>
.category {
font-size: 11px;
font-weight: 700;
font-weight: var(--font-weight-bold);
letter-spacing: 1px;
line-height: 11px;
padding: 10px 0;
margin: 0 12px;
margin: 0 var(--spacing-xs);
border-bottom: 1px solid $node-creator-border-color;
display: flex;
text-transform: uppercase;
@@ -94,7 +56,7 @@ export default Vue.extend({
}
.arrow {
font-size: 12px;
font-size: var(--font-size-2xs);
width: 12px;
color: $node-creator-arrow-color;
}

View File

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

@@ -1,7 +1,6 @@
<template>
<div
:is="transitionsEnabled ? 'transition-group' : 'div'"
class="item-iterator"
:class="$style.itemIterator"
name="accordion"
@before-enter="beforeEnter"
@enter="enter"
@@ -9,108 +8,217 @@
@leave="leave"
>
<div
v-for="(item, index) in elements"
:key="item.key"
:class="item.type"
:data-key="item.key"
v-for="(item, index) in renderedItems"
:key="`${item.key}-${index}`"
data-test-id="item-iterator-item"
:class="{
'clickable': !disabled,
[$style[item.type]]: true,
[$style.active]: activeIndex === index && !disabled,
[$style.iteratorItem]: true
}"
ref="iteratorItems"
@click="wrappedEmit('selected', item)"
>
<creator-item
<category-item
v-if="item.type === 'category'"
:item="item"
:active="activeIndex === index && !disabled"
:clickable="!disabled"
:lastNode="
index === elements.length - 1 || elements[index + 1].type !== 'node'
"
@click="$emit('selected', item)"
@dragstart="emit('dragstart', item, $event)"
@dragend="emit('dragend', item, $event)"
:count="enableGlobalCategoriesCounter ? getCategoryCount(item) : undefined"
/>
<subcategory-item
v-else-if="item.type === 'subcategory'"
:item="item"
/>
<node-item
v-else-if="item.type === 'node'"
:nodeType="item.properties.nodeType"
:allow-actions="withActionsGetter && withActionsGetter(item)"
@dragstart="wrappedEmit('dragstart', item, $event)"
@dragend="wrappedEmit('dragend', item, $event)"
@nodeTypeSelected="$listeners.nodeTypeSelected"
@actionsOpen="$listeners.actionsOpen"
/>
<action-item
v-else-if="item.type === 'action'"
:nodeType="item.properties.nodeType"
:action="item.properties.nodeType"
@dragstart="wrappedEmit('dragstart', item, $event)"
@dragend="wrappedEmit('dragend', item, $event)"
/>
</div>
<aside
v-for="item in elements.length"
v-show="(renderedItems.length < item)"
:key="item"
:class="$style.loadingItem"
>
<n8n-loading :loading="true" :rows="1" variant="p" />
</aside>
</div>
</template>
<script lang="ts">
import { INodeCreateElement } from '@/Interface';
<script setup lang="ts">
import { INodeCreateElement, CategoryCreateElement, NodeCreateElement } from '@/Interface';
import NodeItem from './NodeItem.vue';
import SubcategoryItem from './SubcategoryItem.vue';
import CategoryItem from './CategoryItem.vue';
import ActionItem from './ActionItem.vue';
import { reactive, toRefs, onMounted, watch, onUnmounted, ref } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { NODE_TYPE_COUNT_MAPPER } from '@/constants';
import Vue, { PropType } from 'vue';
import CreatorItem from './CreatorItem.vue';
export interface Props {
elements: INodeCreateElement[];
activeIndex?: number;
disabled?: boolean;
lazyRender?: boolean;
withActionsGetter?: (element: NodeCreateElement) => boolean;
enableGlobalCategoriesCounter?: boolean;
}
export default Vue.extend({
name: 'ItemIterator',
components: {
CreatorItem,
},
props: {
elements: {
type: Array as PropType<INodeCreateElement[]>,
},
activeIndex: {
type: Number,
},
disabled: {
type: Boolean,
},
transitionsEnabled: {
type: Boolean,
},
},
methods: {
emit(eventName: string, element: INodeCreateElement, event: Event) {
if (this.$props.disabled) {
return;
}
this.$emit(eventName, { element, event });
},
beforeEnter(el: HTMLElement) {
el.style.height = '0';
},
enter(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
},
beforeLeave(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
},
leave(el: HTMLElement) {
el.style.height = '0';
},
},
const props = withDefaults(defineProps<Props>(), {
elements: () => [],
});
const emit = defineEmits<{
(event: 'selected', element: INodeCreateElement, $e?: Event): void,
(event: 'dragstart', element: INodeCreateElement, $e: Event): void,
(event: 'dragend', element: INodeCreateElement, $e: Event): void,
}>();
const state = reactive({
renderedItems: [] as INodeCreateElement[],
renderAnimationRequest: 0,
});
const iteratorItems = ref<HTMLElement[]>([]);
function wrappedEmit(event: 'selected' | 'dragstart' | 'dragend', element: INodeCreateElement, $e?: Event) {
if (props.disabled) return;
emit((event as 'selected' || 'dragstart' || 'dragend'), element, $e);
}
function getCategoryCount(item: CategoryCreateElement) {
const { categoriesWithNodes } = useNodeTypesStore();
const currentCategory = categoriesWithNodes[item.category];
const subcategories = Object.keys(currentCategory);
// We need to sum subcategories count for the curent nodeType view
// to get the total count of category
const count = subcategories.reduce((accu: number, subcategory: string) => {
const countKeys = NODE_TYPE_COUNT_MAPPER[useNodeCreatorStore().selectedType];
for (const countKey of countKeys) {
accu += currentCategory[subcategory][(countKey as "triggerCount" | "regularCount")];
}
return accu;
}, 0);
return count;
}
// Lazy render large items lists to prevent the browser from freezing
// when loading many items.
function renderItems() {
if(props.elements.length <= 20 || props.lazyRender === false) {
state.renderedItems = props.elements;
return;
};
if (state.renderedItems.length < props.elements.length) {
state.renderedItems.push(...props.elements.slice(state.renderedItems.length, state.renderedItems.length + 10));
state.renderAnimationRequest = window.requestAnimationFrame(renderItems);
}
}
function beforeEnter(el: HTMLElement) {
el.style.height = '0';
}
function enter(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
}
function beforeLeave(el: HTMLElement) {
el.style.height = `${el.scrollHeight}px`;
}
function leave(el: HTMLElement) {
el.style.height = '0';
}
onMounted(() => {
renderItems();
});
onUnmounted(() => {
window.cancelAnimationFrame(state.renderAnimationRequest);
state.renderedItems = [];
});
// Make sure the active item is always visible
// scroll if needed
watch(() => props.activeIndex, async () => {
if(props.activeIndex === undefined) return;
iteratorItems.value[props.activeIndex]?.scrollIntoView({ block: 'nearest' });
});
// Trigger elements re-render when they change
watch(() => props.elements, async () => {
window.cancelAnimationFrame(state.renderAnimationRequest);
state.renderedItems = [];
renderItems();
});
const { renderedItems } = toRefs(state);
</script>
<style lang="scss" scoped>
.item-iterator > *:last-child {
margin-bottom: var(--spacing-2xl);
}
.accordion-enter {
opacity: 0;
<style lang="scss" module>
.loadingItem {
height: 48px;
margin: 0 var(--search-margin, var(--spacing-s));
}
.iteratorItem {
// Make sure border is fully visible
margin-left: 1px;
position: relative;
&::before {
content: "";
position: absolute;
left: -1px;
top: 0;
bottom: 0;
border-left: 2px solid transparent;
}
&:hover::before {
border-color: $node-creator-item-hover-border-color;
}
.accordion-leave-active {
opacity: 1;
}
&.active::before {
border-color: $color-primary !important;
}
.accordion-leave-active {
transition: all 0.25s ease, opacity 0.1s ease;
margin-top: 0;
}
&.category.singleCategory {
display: none;
}
.accordion-enter-active {
transition: all 0.25s ease, opacity 0.25s ease;
margin-top: 0;
}
.accordion-leave-to {
opacity: 0;
.itemIterator {
> *:last-child {
margin-bottom: var(--spacing-2xl);
}
}
.accordion-enter-to {
opacity: 1;
.action {
&:last-of-type {
margin-bottom: var(--spacing-s);
}
}
.node + .category {
margin-top: 15px;
margin-top: var(--spacing-s);
}
</style>

View File

@@ -5,9 +5,8 @@
>
<div class="main-panel">
<trigger-helper-panel
v-if="selectedType === TRIGGER_NODE_FILTER"
:searchItems="searchItems"
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
v-if="nodeCreatorStore.selectedType === TRIGGER_NODE_FILTER"
@nodeTypeSelected="$listeners.nodeTypeSelected"
>
<template #header>
<type-selector/>
@@ -15,10 +14,15 @@
</trigger-helper-panel>
<categorized-items
v-else
enable-global-categories-counter
:categorizedItems="categorizedItems"
:categoriesWithNodes="categoriesWithNodes"
:searchItems="searchItems"
:excludedSubcategories="[OTHER_TRIGGER_NODES_SUBCATEGORY]"
:initialActiveCategories="[CORE_NODES_CATEGORY]"
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
:allItems="categorizedItems"
@nodeTypeSelected="$listeners.nodeTypeSelected"
@actionsOpen="() => {}"
>
<template #header>
<type-selector />
@@ -28,73 +32,58 @@
</div>
</template>
<script lang="ts">
import { PropType } from 'vue';
<script setup lang="ts">
import { watch, getCurrentInstance, onMounted, onUnmounted } from 'vue';
import { externalHooks } from '@/mixins/externalHooks';
import mixins from 'vue-typed-mixins';
import TriggerHelperPanel from './TriggerHelperPanel.vue';
import { ALL_NODE_FILTER, TRIGGER_NODE_FILTER, OTHER_TRIGGER_NODES_SUBCATEGORY, CORE_NODES_CATEGORY } from '@/constants';
import CategorizedItems from './CategorizedItems.vue';
import TypeSelector from './TypeSelector.vue';
import { INodeCreateElement } from '@/Interface';
import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { useNodeTypesStore } from '@/stores/nodeTypes';
export default mixins(externalHooks).extend({
name: 'NodeCreateList',
components: {
TriggerHelperPanel,
CategorizedItems,
TypeSelector,
},
props: {
searchItems: {
type: Array as PropType<INodeCreateElement[] | null>,
},
},
data() {
return {
CORE_NODES_CATEGORY,
TRIGGER_NODE_FILTER,
ALL_NODE_FILTER,
OTHER_TRIGGER_NODES_SUBCATEGORY,
};
},
computed: {
...mapStores(
useNodeCreatorStore,
useWorkflowsStore,
),
selectedType(): string {
return this.nodeCreatorStore.selectedType;
},
},
watch: {
selectedType(newValue, oldValue) {
this.$externalHooks().run('nodeCreateList.selectedTypeChanged', {
oldValue,
newValue,
});
this.$telemetry.trackNodesPanel('nodeCreateList.selectedTypeChanged', {
old_filter: oldValue,
new_filter: newValue,
workflow_id: this.workflowsStore.workflowId,
});
},
},
mounted() {
this.$externalHooks().run('nodeCreateList.mounted');
// Make sure tabs are visible on mount
this.nodeCreatorStore.showTabs = true;
},
destroyed() {
this.nodeCreatorStore.selectedType = ALL_NODE_FILTER;
this.$externalHooks().run('nodeCreateList.destroyed');
this.$telemetry.trackNodesPanel('nodeCreateList.destroyed', { workflow_id: this.workflowsStore.workflowId });
},
export interface Props {
searchItems?: INodeCreateElement[];
}
withDefaults(defineProps<Props>(), {
searchItems: () => [],
});
const instance = getCurrentInstance();
const { $externalHooks } = new externalHooks();
const { workflowId } = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
const { categorizedItems, categoriesWithNodes } = useNodeTypesStore();
watch(() => nodeCreatorStore.selectedType, (newValue, oldValue) => {
$externalHooks().run('nodeCreateList.selectedTypeChanged', {
oldValue,
newValue,
});
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.selectedTypeChanged', {
old_filter: oldValue,
new_filter: newValue,
workflow_id: workflowId,
});
});
onMounted(() => {
$externalHooks().run('nodeCreateList.mounted');
// Make sure tabs are visible on mount
nodeCreatorStore.setShowTabs(true);
});
onUnmounted(() => {
nodeCreatorStore.setSelectedType(ALL_NODE_FILTER);
$externalHooks().run('nodeCreateList.destroyed');
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.destroyed', { workflow_id: workflowId });
});
</script>
<style lang="scss" scoped>
.container {
height: 100%;

View File

@@ -1,5 +1,5 @@
<template>
<div :class="$style.noResults">
<div :class="{[$style.noResults]: true, [$style.iconless]: !showIcon}">
<div :class="$style.icon" v-if="showIcon">
<no-results-icon />
</div>
@@ -29,30 +29,16 @@
</template>
<script lang="ts">
<script setup lang="ts">
import { REQUEST_NODE_FORM_URL } from '@/constants';
import Vue from 'vue';
import NoResultsIcon from './NoResultsIcon.vue';
export default Vue.extend({
name: 'NoResults',
props: {
showRequest: {
type: Boolean,
},
showIcon: {
type: Boolean,
},
},
components: {
NoResultsIcon,
},
data() {
return {
REQUEST_NODE_FORM_URL,
};
},
});
export interface Props {
showIcon?: boolean;
showRequest?: boolean;
}
defineProps<Props>();
</script>
<style lang="scss" module>
@@ -79,9 +65,9 @@ export default Vue.extend({
}
}
.action, .request {
.action p, .request p {
font-size: var(--font-size-s);
line-height: var(--font-line-height-compact);
line-height: var(--font-line-height-xloose);
}
.request {

View File

@@ -1,6 +1,6 @@
<template>
<div>
<aside :class="{'node-creator-scrim': true, expanded: !uiStore.sidebarMenuCollapsed, active: showScrim}" />
<aside :class="{'node-creator-scrim': true, active: nodeCreatorStore.showScrim}" />
<slide-transition>
<div
@@ -10,10 +10,12 @@
v-click-outside="onClickOutside"
@dragover="onDragOver"
@drop="onDrop"
@mousedown="onMouseDown"
@mouseup="onMouseUp"
data-test-id="node-creator"
>
<main-panel
@nodeTypeSelected="nodeTypeSelected"
@nodeTypeSelected="$listeners.nodeTypeSelected"
:searchItems="searchItems"
/>
</div>
@@ -21,96 +23,103 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import Vue from 'vue';
import { computed, watch, reactive, toRefs } from 'vue';
import { INodeCreateElement } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import SlideTransition from '../../transitions/SlideTransition.vue';
import SlideTransition from '@/components/transitions/SlideTransition.vue';
import MainPanel from './MainPanel.vue';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
export default Vue.extend({
name: 'NodeCreator',
components: {
MainPanel,
SlideTransition,
},
props: {
active: {
type: Boolean,
},
},
computed: {
...mapStores(
useNodeCreatorStore,
useNodeTypesStore,
useUIStore,
),
showScrim(): boolean {
return this.nodeCreatorStore.showScrim;
},
visibleNodeTypes(): INodeTypeDescription[] {
return this.nodeTypesStore.visibleNodeTypes;
},
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;
});
export interface Props {
active?: boolean;
}
return sorted.map((nodeType) => ({
type: 'node',
category: '',
key: `${nodeType.name}`,
properties: {
nodeType,
subcategory: '',
},
includedByTrigger: nodeType.group.includes('trigger'),
includedByRegular: !nodeType.group.includes('trigger'),
}));
},
},
methods: {
onClickOutside (e: Event) {
if (e.type === 'click') {
this.$emit('closeNodeCreator');
}
},
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
onDragOver(event: DragEvent) {
event.preventDefault();
},
onDrop(event: DragEvent) {
if (!event.dataTransfer) {
return;
}
const props = defineProps<Props>();
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
const nodeCreatorBoundingRect = (this.$refs.nodeCreator as Element).getBoundingClientRect();
const emit = defineEmits<{
(event: 'closeNodeCreator'): void,
}>();
// Abort drag end event propagation if dropped inside nodes panel
if (nodeTypeName && event.pageX >= nodeCreatorBoundingRect.x && event.pageY >= nodeCreatorBoundingRect.y) {
event.stopPropagation();
}
},
},
watch: {
active(isActive) {
if(isActive === false) this.nodeCreatorStore.showScrim = false;
},
},
const nodeCreatorStore = useNodeCreatorStore();
const state = reactive({
nodeCreator: null as HTMLElement | null,
mousedownInsideEvent: null as MouseEvent | null,
});
const visibleNodeTypes = computed(() => useNodeTypesStore().visibleNodeTypes);
const searchItems = computed<INodeCreateElement[]>(() => {
const sorted = [...visibleNodeTypes.value];
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'),
}));
});
function onClickOutside (event: Event) {
// We need to prevent cases where user would click inside the node creator
// and try to drag undraggable element. In that case the click event would
// be fired and the node creator would be closed. So we stop that if we detect
// that the click event originated from inside the node creator. And fire click even on the
// original target.
if(state.mousedownInsideEvent) {
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
state.mousedownInsideEvent.target?.dispatchEvent(clickEvent);
state.mousedownInsideEvent = null;
return;
};
if (event.type === 'click') {
emit('closeNodeCreator');
}
}
function onMouseUp() {
state.mousedownInsideEvent = null;
}
function onMouseDown(event: MouseEvent) {
state.mousedownInsideEvent = event;
}
function onDragOver(event: DragEvent) {
event.preventDefault();
}
function onDrop(event: DragEvent) {
if (!event.dataTransfer) {
return;
}
const nodeTypeName = event.dataTransfer.getData('nodeTypeName');
const nodeCreatorBoundingRect = (state.nodeCreator as Element).getBoundingClientRect();
// Abort drag end event propagation if dropped inside nodes panel
if (nodeTypeName && event.pageX >= nodeCreatorBoundingRect.x && event.pageY >= nodeCreatorBoundingRect.y) {
event.stopPropagation();
}
}
watch(() => props.active, (isActive) => {
if(isActive === false) nodeCreatorStore.setShowScrim(false);
});
const { nodeCreator } = toRefs(state);
</script>
<style scoped lang="scss">
@@ -140,10 +149,6 @@ export default Vue.extend({
pointer-events: none;
transition: opacity 200ms ease-in-out;
&.expanded {
left: $sidebar-expanded-width
}
&.active {
opacity: 0.7;
}

View File

@@ -1,206 +1,180 @@
<template>
<div
draggable
<!-- Node Item is draggable only if it doesn't contain actions -->
<n8n-node-creator-node
:draggable="!showActionArrow"
@dragstart="onDragStart"
@dragend="onDragEnd"
:class="{[$style['node-item']]: true}"
@click.stop="onClick"
:class="$style.nodeItem"
:description="allowActions ? undefined : description"
:title="displayName"
:isTrigger="!allowActions && isTriggerNode"
:show-action-arrow="showActionArrow"
>
<node-icon :class="$style['node-icon']" :nodeType="nodeType" />
<div>
<div :class="$style.details">
<span :class="$style.name" data-test-id="node-item-name">
{{ $locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: nodeType.displayName,
})
}}
</span>
<span v-if="isTrigger" :class="$style['trigger-icon']">
<trigger-icon />
</span>
<n8n-tooltip v-if="isCommunityNode" placement="top" data-test-id="node-item-community-tooltip">
<template #content>
<div
:class="$style['community-node-icon']"
v-html="$locale.baseText('generic.communityNode.tooltip', { interpolate: { packageName: nodeType.name.split('.')[0], docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL } })"
@click="onCommunityNodeTooltipClick"
>
</div>
</template>
<n8n-icon icon="cube" />
</n8n-tooltip>
</div>
<div :class="$style.description">
{{ $locale.headerText({
key: `headers.${shortNodeType}.description`,
fallback: nodeType.description,
})
}}
</div>
<template #icon>
<node-icon :nodeType="nodeType" />
</template>
<div :class="$style['draggable-data-transfer']" ref="draggableDataTransfer" />
<transition name="node-item-transition">
<div
:class="$style.draggable"
:style="draggableStyle"
ref="draggable"
v-show="dragging"
>
<node-icon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" />
</div>
</transition>
</div>
</div>
<template #tooltip v-if="isCommunityNode">
<p
:class="$style.communityNodeIcon"
v-html="$locale.baseText('generic.communityNode.tooltip', { interpolate: { packageName: nodeType.name.split('.')[0], docURL: COMMUNITY_NODES_INSTALLATION_DOCS_URL } })"
@click="onCommunityNodeTooltipClick"
/>
</template>
<template #dragContent>
<div :class="$style.draggableDataTransfer" ref="draggableDataTransfer"/>
<div
:class="$style.draggable"
:style="draggableStyle"
v-show="dragging"
>
<node-icon :nodeType="nodeType" @click.capture.stop :size="40" :shrink="false" />
</div>
</template>
</n8n-node-creator-node>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
<script setup lang="ts">
import { reactive, computed, toRefs, getCurrentInstance } from 'vue';
import { INodeTypeDescription } from 'n8n-workflow';
import { isCommunityPackageName } from '@/utils';
import { getNewNodePosition, NODE_SIZE } from '@/utils/nodeViewUtils';
import { isCommunityPackageName } from '@/utils';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL } from '@/constants';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import NodeIcon from '@/components/NodeIcon.vue';
import TriggerIcon from '@/components/TriggerIcon.vue';
Vue.component('node-icon', NodeIcon);
Vue.component('trigger-icon', TriggerIcon);
export interface Props {
nodeType: INodeTypeDescription;
active?: boolean;
allowActions?: boolean;
}
export default Vue.extend({
name: 'NodeItem',
props: {
nodeType: {
type: Object as PropType<INodeTypeDescription>,
},
active: {
type: Boolean,
},
},
data() {
return {
dragging: false,
draggablePosition: {
x: -100,
y: -100,
},
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
};
},
computed: {
shortNodeType(): string {
return this.$locale.shortNodeType(this.nodeType.name);
},
isTrigger (): boolean {
return this.nodeType.group.includes('trigger');
},
draggableStyle(): { top: string; left: string; } {
return {
top: `${this.draggablePosition.y}px`,
left: `${this.draggablePosition.x}px`,
};
},
isCommunityNode(): boolean {
return isCommunityPackageName(this.nodeType.name);
},
},
methods: {
onDragStart(event: DragEvent): void {
/**
* Workaround for firefox, that doesn't attach the pageX and pageY coordinates to "ondrag" event.
* All browsers attach the correct page coordinates to the "dragover" event.
* @bug https://bugzilla.mozilla.org/show_bug.cgi?id=505521
*/
document.body.addEventListener("dragover", this.onDragOver);
const { pageX: x, pageY: y } = event;
this.$emit('dragstart', event);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.dropEffect = "copy";
event.dataTransfer.setData('nodeTypeName', this.nodeType.name);
event.dataTransfer.setDragImage(this.$refs.draggableDataTransfer as Element, 0, 0);
}
this.dragging = true;
this.draggablePosition = { x, y };
},
onDragOver(event: DragEvent): void {
if (!this.dragging || event.pageX === 0 && event.pageY === 0) {
return;
}
const [x,y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
this.draggablePosition = { x, y };
},
onDragEnd(event: DragEvent): void {
document.body.removeEventListener("dragover", this.onDragOver);
this.$emit('dragend', event);
this.dragging = false;
setTimeout(() => {
this.draggablePosition = { x: -100, y: -100 };
}, 300);
},
onCommunityNodeTooltipClick(event: MouseEvent) {
if ((event.target as Element).localName === 'a') {
this.$telemetry.track('user clicked cnr docs link', { source: 'nodes panel node' });
}
},
},
const props = withDefaults(defineProps<Props>(), {
active: false,
allowActions: false,
});
</script>
const emit = defineEmits<{
(event: 'dragstart', $e: DragEvent): void,
(event: 'dragend', $e: DragEvent): void,
(event: 'nodeTypeSelected', value: string[]): void,
(event: 'actionsOpen', value: INodeTypeDescription): void,
}>();
const instance = getCurrentInstance();
const state = reactive({
dragging: false,
draggablePosition: {
x: -100,
y: -100,
},
draggableDataTransfer: null as Element | null,
});
const description = computed<string>(() => {
return instance?.proxy.$locale.headerText({
key: `headers.${shortNodeType.value}.description`,
fallback: props.nodeType.description,
}) as string;
});
const showActionArrow = computed(() => props.allowActions && hasActions.value);
const hasActions = computed<boolean>(() => (props.nodeType.actions?.length || 0) > 0);
const shortNodeType = computed<string>(() => instance?.proxy.$locale.shortNodeType(props.nodeType.name) || '');
const draggableStyle = computed<{ top: string; left: string; }>(() => ({
top: `${state.draggablePosition.y}px`,
left: `${state.draggablePosition.x}px`,
}));
const isCommunityNode = computed<boolean>(() => isCommunityPackageName(props.nodeType.name));
const displayName = computed<any>(() => {
const displayName = props.nodeType.displayName.trimEnd();
return instance?.proxy.$locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: props.allowActions ? displayName.replace('Trigger', '') : displayName,
});
});
const isTriggerNode = computed<boolean>(() => props.nodeType.displayName.toLowerCase().includes('trigger'));
function onClick() {
if(hasActions.value && props.allowActions) emit('actionsOpen', props.nodeType);
else emit('nodeTypeSelected', [props.nodeType.name]);
}
function onDragStart(event: DragEvent): void {
/**
* Workaround for firefox, that doesn't attach the pageX and pageY coordinates to "ondrag" event.
* All browsers attach the correct page coordinates to the "dragover" event.
* @bug https://bugzilla.mozilla.org/show_bug.cgi?id=505521
*/
document.body.addEventListener("dragover", onDragOver);
const { pageX: x, pageY: y } = event;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.dropEffect = "copy";
event.dataTransfer.setDragImage(state.draggableDataTransfer as Element, 0, 0);
event.dataTransfer.setData(
'nodeTypeName',
useNodeCreatorStore().getNodeTypesWithManualTrigger(props.nodeType.name).join(','),
);
}
state.dragging = true;
state.draggablePosition = { x, y };
emit('dragstart', event);
}
function onDragOver(event: DragEvent): void {
if (!state.dragging || event.pageX === 0 && event.pageY === 0) {
return;
}
const [x,y] = getNewNodePosition([], [event.pageX - NODE_SIZE / 2, event.pageY - NODE_SIZE / 2]);
state.draggablePosition = { x, y };
}
function onDragEnd(event: DragEvent): void {
document.body.removeEventListener("dragover", onDragOver);
emit('dragend', event);
state.dragging = false;
setTimeout(() => {
state.draggablePosition = { x: -100, y: -100 };
}, 300);
}
function onCommunityNodeTooltipClick(event: MouseEvent) {
if ((event.target as Element).localName === 'a') {
instance?.proxy.$telemetry.track('user clicked cnr docs link', { source: 'nodes panel node' });
}
}
defineExpose({
onClick,
});
const { dragging, draggableDataTransfer } = toRefs(state);
</script>
<style lang="scss" module>
.node-item {
padding: 11px 8px 11px 0;
.nodeItem {
--trigger-icon-background-color: #{$trigger-icon-background-color};
--trigger-icon-border-color: #{$trigger-icon-border-color};
margin-left: 15px;
margin-right: 12px;
display: flex;
cursor: grab;
user-select: none;
}
.details {
align-items: center;
}
.node-icon {
min-width: 26px;
max-width: 26px;
margin-right: 15px;
}
.name {
font-weight: var(--font-weight-bold);
font-size: 14px;
line-height: 18px;
margin-right: 5px;
}
.packageName {
margin-right: 5px;
}
.description {
margin-top: 2px;
font-size: var(--font-size-2xs);
line-height: 16px;
font-weight: 400;
color: $node-creator-description-color;
}
.trigger-icon {
height: 16px;
width: 16px;
display: inline-block;
margin-right: var(--spacing-3xs);
vertical-align: middle;
}
.community-node-icon {
.communityNodeIcon {
vertical-align: top;
}
@@ -218,29 +192,8 @@ export default Vue.extend({
align-items: center;
}
.draggable-data-transfer {
.draggableDataTransfer {
width: 1px;
height: 1px;
}
</style>
<style lang="scss" scoped>
.node-item-transition {
&-enter-active,
&-leave-active {
transition-property: opacity, transform;
transition-duration: 300ms;
transition-timing-function: ease;
}
&-enter,
&-leave-to {
opacity: 0;
transform: scale(0);
}
}
.el-tooltip svg {
color: var(--color-foreground-xdark);
}
</style>

View File

@@ -5,12 +5,14 @@
</div>
<div :class="$style.text">
<input
:placeholder="$locale.baseText('nodeCreator.searchBar.searchNodes')"
ref="input"
:placeholder="placeholder"
:value="value"
@input="onInput"
:class="$style.input"
ref="inputRef"
autofocus
data-test-id="node-creator-search-bar"
tabindex="0"
/>
</div>
<div :class="$style.suffix" v-if="value.length > 0" @click="clear">
@@ -21,50 +23,56 @@
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
import mixins from 'vue-typed-mixins';
<script setup lang="ts">
import Vue, { onMounted, reactive, toRefs, onBeforeUnmount } from 'vue';
import { externalHooks } from '@/mixins/externalHooks';
export default mixins(externalHooks).extend({
name: "SearchBar",
props: {
value: {
type: String,
},
eventBus: {
type: Object as PropType<Vue>,
},
},
mounted() {
if (this.eventBus) {
this.eventBus.$on("focus", this.focus);
}
setTimeout(this.focus, 0);
export interface Props {
placeholder: string;
value: string;
eventBus?: Vue;
}
this.$externalHooks().run('nodeCreator_searchBar.mount', { inputRef: this.$refs['input'] });
},
methods: {
focus() {
const input = this.$refs.input as HTMLInputElement;
if (input) {
input.focus();
}
},
onInput(event: InputEvent) {
const input = event.target as HTMLInputElement;
this.$emit("input", input.value);
},
clear() {
this.$emit("input", "");
},
},
beforeDestroy() {
if (this.eventBus) {
this.eventBus.$off("focus", this.focus);
}
},
withDefaults(defineProps<Props>(), {
placeholder: '',
value: '',
});
const emit = defineEmits<{
(event: 'input', value: string): void,
}>();
const { $externalHooks } = new externalHooks();
const state = reactive({
inputRef: null as HTMLInputElement | null,
});
function focus() {
state.inputRef?.focus();
}
function onInput(event: Event) {
const input = event.target as HTMLInputElement;
emit("input", input.value);
}
function clear() {
emit("input", "");
}
onMounted(() => {
$externalHooks().run('nodeCreator_searchBar.mount', { inputRef: state.inputRef });
setTimeout(focus, 0);
});
onBeforeUnmount(() => {
state.inputRef?.remove();
});
const { inputRef } = toRefs(state);
defineExpose({
focus,
});
</script>
@@ -74,7 +82,7 @@ export default mixins(externalHooks).extend({
height: 40px;
padding: var(--spacing-s) var(--spacing-xs);
align-items: center;
margin: var(--spacing-s);
margin: var(--search-margin, var(--spacing-s));
filter: drop-shadow(0px 2px 5px rgba(46, 46, 50, 0.04));
border: 1px solid $node-creator-border-color;

View File

@@ -56,6 +56,7 @@ export default Vue.extend({
.subcategory {
display: flex;
padding: 11px 16px 11px 30px;
user-select: none;
}
.subcategoryWithIcon {

View File

@@ -1,167 +1,364 @@
<template>
<div :class="{ [$style.triggerHelperContainer]: true, [$style.isRoot]: isRoot }">
<categorized-items
ref="categorizedItems"
@subcategoryClose="onSubcategoryClose"
@onSubcategorySelected="onSubcategorySelected"
@nodeTypeSelected="nodeType => $emit('nodeTypeSelected', nodeType)"
:expandAllCategories="isActionsActive"
:subcategoryOverride="nodeAppSubcategory"
:alwaysShowSearch="isActionsActive"
:categorizedItems="computedCategorizedItems"
:categoriesWithNodes="computedCategoriesWithNodes"
:initialActiveIndex="0"
:searchItems="searchItems"
:firstLevelItems="isRoot ? items : []"
:excludedCategories="isRoot ? [] : [CORE_NODES_CATEGORY]"
:initialActiveCategories="[COMMUNICATION_CATEGORY]"
:withActionsGetter="shouldShowNodeActions"
:firstLevelItems="firstLevelItems"
:showSubcategoryIcon="isActionsActive"
:flatten="!isActionsActive && isAppEventSubcategory"
:filterByType="false"
:lazyRender="true"
:allItems="allNodes"
:searchPlaceholder="searchPlaceholder"
ref="categorizedItemsRef"
@subcategoryClose="onSubcategoryClose"
@onSubcategorySelected="onSubcategorySelected"
@nodeTypeSelected="onNodeTypeSelected"
@actionsOpen="setActiveActionsNodeType"
@actionSelected="onActionSelected"
>
<template #noResultsTitle v-if="isActionsActive">
<i />
</template>
<template #noResultsAction v-if="isActionsActive">
<p v-if="containsAPIAction" v-html="getCustomAPICallHintLocale('apiCallNoResult')" class="clickable" @click.stop="addHttpNode" />
<p v-else v-text="$locale.baseText('nodeCreator.noResults.noMatchingActions')"/>
</template>
<template #header>
<slot name="header" />
<p v-if="isRoot" v-text="$locale.baseText('nodeCreator.triggerHelperPanel.title')" :class="$style.title" />
<p
v-if="isRoot"
v-text="$locale.baseText('nodeCreator.triggerHelperPanel.title')"
:class="$style.title"
/>
</template>
<template #footer v-if="(activeNodeActions && containsAPIAction)">
<slot name="footer" />
<span v-html="getCustomAPICallHintLocale('apiCall')" class="clickable" @click.stop="addHttpNode" />
</template>
</categorized-items>
</div>
</template>
<script lang="ts">
import { PropType } from 'vue';
import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/mixins/externalHooks';
import { INodeCreateElement } from '@/Interface';
import { CORE_NODES_CATEGORY, WEBHOOK_NODE_TYPE, OTHER_TRIGGER_NODES_SUBCATEGORY, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, COMMUNICATION_CATEGORY, SCHEDULE_TRIGGER_NODE_TYPE } from '@/constants';
import ItemIterator from './ItemIterator.vue';
<script setup lang="ts">
import { reactive, toRefs, getCurrentInstance, computed, onMounted, ref } from 'vue';
import { INodeTypeDescription, INodeActionTypeDescription, INodeTypeNameVersion } from 'n8n-workflow';
import { INodeCreateElement, IActionItemProps, SubcategoryCreateElement, IUpdateInformation } from '@/Interface';
import { CORE_NODES_CATEGORY, WEBHOOK_NODE_TYPE, OTHER_TRIGGER_NODES_SUBCATEGORY, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, MANUAL_TRIGGER_NODE_TYPE, SCHEDULE_TRIGGER_NODE_TYPE, EMAIL_IMAP_NODE_TYPE, CUSTOM_API_CALL_NAME, HTTP_REQUEST_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import CategorizedItems from './CategorizedItems.vue';
import SearchBar from './SearchBar.vue';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { getCategoriesWithNodes, getCategorizedList } from "@/utils";
import { externalHooks } from '@/mixins/externalHooks';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { BaseTextKey } from '@/plugins/i18n';
export default mixins(externalHooks).extend({
name: 'TriggerHelperPanel',
components: {
ItemIterator,
CategorizedItems,
SearchBar,
},
props: {
searchItems: {
type: Array as PropType<INodeCreateElement[] | null>,
const instance = getCurrentInstance();
const items: INodeCreateElement[] = [{
key: "*",
type: "subcategory",
title: instance?.proxy.$locale.baseText('nodeCreator.subcategoryNames.appTriggerNodes'),
properties: {
subcategory: "App Trigger Nodes",
description: instance?.proxy.$locale.baseText('nodeCreator.subcategoryDescriptions.appTriggerNodes'),
icon: "fa:satellite-dish",
defaults: {
color: "#7D838F",
},
},
},
data() {
return {
CORE_NODES_CATEGORY,
COMMUNICATION_CATEGORY,
isRoot: true,
};
},
computed: {
items() {
return [{
key: "*",
type: "subcategory",
title: this.$locale.baseText('nodeCreator.subcategoryNames.appTriggerNodes'),
properties: {
subcategory: "App Trigger Nodes",
description: this.$locale.baseText('nodeCreator.subcategoryDescriptions.appTriggerNodes'),
icon: "fa:satellite-dish",
defaults: {
color: "#7D838F",
},
},
},
{
key: SCHEDULE_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
{
key: SCHEDULE_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: SCHEDULE_TRIGGER_NODE_TYPE,
displayName: this.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName'),
description: this.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDescription'),
icon: "fa:clock",
defaults: {
color: "#7D838F",
},
},
},
group: [],
name: SCHEDULE_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDisplayName'),
description: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.scheduleTriggerDescription'),
icon: "fa:clock",
defaults: {
color: "#7D838F",
},
{
key: WEBHOOK_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: WEBHOOK_NODE_TYPE,
displayName: this.$locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDisplayName'),
description: this.$locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDescription'),
iconData: {
type: "file",
icon: "webhook",
fileBuffer: "/static/webhook-icon.svg",
},
defaults: {
color: "#7D838F",
},
},
},
},
{
key: MANUAL_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: MANUAL_TRIGGER_NODE_TYPE,
displayName: this.$locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
description: this.$locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDescription'),
icon: "fa:mouse-pointer",
defaults: {
color: "#7D838F",
},
},
},
},
{
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
displayName: this.$locale.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDisplayName'),
description: this.$locale.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDescription'),
icon: "fa:sign-out-alt",
defaults: {
color: "#7D838F",
},
},
},
},
{
type: "subcategory",
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: OTHER_TRIGGER_NODES_SUBCATEGORY,
description: this.$locale.baseText('nodeCreator.subcategoryDescriptions.otherTriggerNodes'),
icon: "fa:folder-open",
defaults: {
color: "#7D838F",
},
},
},
];
},
},
},
methods: {
isRootSubcategory(subcategory: INodeCreateElement) {
return this.items.find(item => item.key === subcategory.key) !== undefined;
},
onSubcategorySelected() {
this.isRoot = false;
},
onSubcategoryClose(subcategory: INodeCreateElement) {
this.isRoot = this.isRootSubcategory(subcategory);
{
key: WEBHOOK_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: WEBHOOK_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDisplayName'),
description: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.webhookTriggerDescription'),
iconData: {
type: "file",
icon: "webhook",
fileBuffer: "/static/webhook-icon.svg",
},
defaults: {
color: "#7D838F",
},
},
},
},
{
key: MANUAL_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: MANUAL_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDisplayName'),
description: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.manualTriggerDescription'),
icon: "fa:mouse-pointer",
defaults: {
color: "#7D838F",
},
},
},
},
{
key: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
group: [],
name: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
displayName: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDisplayName'),
description: instance?.proxy.$locale.baseText('nodeCreator.triggerHelperPanel.workflowTriggerDescription'),
icon: "fa:sign-out-alt",
defaults: {
color: "#7D838F",
},
},
},
},
{
type: "subcategory",
key: OTHER_TRIGGER_NODES_SUBCATEGORY,
category: CORE_NODES_CATEGORY,
properties: {
subcategory: OTHER_TRIGGER_NODES_SUBCATEGORY,
description: instance?.proxy.$locale.baseText('nodeCreator.subcategoryDescriptions.otherTriggerNodes'),
icon: "fa:folder-open",
defaults: {
color: "#7D838F",
},
},
},
];
const emit = defineEmits({
"nodeTypeSelected": (nodeTypes: string[]) => true,
});
const state = reactive({
isRoot: true,
selectedSubcategory: '',
activeNodeActions: null as INodeTypeDescription | null,
latestNodeData: null as INodeTypeDescription | null,
});
const categorizedItemsRef = ref<InstanceType<typeof CategorizedItems>>();
const { $externalHooks } = new externalHooks();
const {
mergedAppNodes,
setShowTabs,
getActionData,
getNodeTypesWithManualTrigger,
setAddedNodeActionParameters,
} = useNodeCreatorStore();
const telemetry = instance?.proxy.$telemetry;
const { categorizedItems: allNodes, isTriggerNode } = useNodeTypesStore();
const containsAPIAction = computed(() => state.latestNodeData?.properties.some((p) => p.options?.find((o) => o.name === CUSTOM_API_CALL_NAME)) === true);
const computedCategorizedItems = computed(() => getCategorizedList(computedCategoriesWithNodes.value, true));
const nodeAppSubcategory = computed<(SubcategoryCreateElement | undefined)>(() => {
if(!state.activeNodeActions) return undefined;
return {
type: 'subcategory',
properties: {
subcategory: state.activeNodeActions.displayName,
nodeType: {
description: '',
key: state.activeNodeActions.name,
iconUrl: state.activeNodeActions.iconUrl,
icon: state.activeNodeActions.icon,
},
},
};
});
const searchPlaceholder = computed(() => {
const nodeNameTitle = state.activeNodeActions?.displayName?.trim() as string;
const actionsSearchPlaceholder = instance?.proxy.$locale.baseText(
'nodeCreator.actionsCategory.searchActions',
{ interpolate: { nodeNameTitle }},
);
return isActionsActive.value ? actionsSearchPlaceholder : undefined;
});
const filteredMergedAppNodes = computed(() => {
const WHITELISTED_APP_CORE_NODES = [
EMAIL_IMAP_NODE_TYPE,
WEBHOOK_NODE_TYPE,
];
if(isAppEventSubcategory.value) return mergedAppNodes.filter(node => {
const isRegularNode = !isTriggerNode(node.name);
const isStickyNode = node.name === STICKY_NODE_TYPE;
const isCoreNode = node.codex?.categories?.includes(CORE_NODES_CATEGORY) && !WHITELISTED_APP_CORE_NODES.includes(node.name);
const hasActions = (node.actions || []).length > 0;
if(isRegularNode && !hasActions) return false;
return !isCoreNode && !isStickyNode;
});
return mergedAppNodes;
});
const computedCategoriesWithNodes = computed(() => {
if(!state.activeNodeActions) return getCategoriesWithNodes(filteredMergedAppNodes.value, []);
return getCategoriesWithNodes(selectedNodeActions.value, [], state.activeNodeActions.displayName) ;
});
const selectedNodeActions = computed<INodeActionTypeDescription[]>(() => state.activeNodeActions?.actions ?? []);
const isAppEventSubcategory = computed(() => state.selectedSubcategory === "*");
const isActionsActive = computed(() => state.activeNodeActions !== null);
const firstLevelItems = computed(() => isRoot.value ? items : []);
const isSearchActive = computed(() => useNodeCreatorStore().itemsFilter !== '');
const searchItems = computed<INodeCreateElement[]>(() => {
const sorted = state.activeNodeActions ? [...selectedNodeActions.value] : [...filteredMergedAppNodes.value];
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: state.activeNodeActions ? state.activeNodeActions.displayName : '',
},
includedByTrigger: nodeType.group.includes('trigger'),
includedByRegular: !nodeType.group.includes('trigger'),
}));
});
function onNodeTypeSelected(nodeTypes: string[]) {
emit("nodeTypeSelected", nodeTypes.length === 1 ? getNodeTypesWithManualTrigger(nodeTypes[0]) : nodeTypes);
}
function getCustomAPICallHintLocale(key: string) {
if(!state.activeNodeActions) return '';
const nodeNameTitle = state.activeNodeActions.displayName;
return instance?.proxy.$locale.baseText(
`nodeCreator.actionsList.${key}` as BaseTextKey,
{ interpolate: { nodeNameTitle }},
);
}
// The nodes.json doesn't contain API CALL option so we need to fetch the node detail
// to determine if need to render the API CALL hint
async function fetchNodeDetails() {
if(!state.activeNodeActions) return;
const { getNodesInformation } = useNodeTypesStore();
const { version, name } = state.activeNodeActions;
const payload = {
name,
version: Array.isArray(version) ? version?.slice(-1)[0] : version,
} as INodeTypeNameVersion;
const nodesInfo = await getNodesInformation([payload], false);
state.latestNodeData = nodesInfo[0];
}
function setActiveActionsNodeType(nodeType: INodeTypeDescription | null) {
state.activeNodeActions = nodeType;
setShowTabs(false);
fetchNodeDetails();
if(nodeType) trackActionsView();
}
function onActionSelected(actionCreateElement: INodeCreateElement) {
const action = (actionCreateElement.properties as IActionItemProps).nodeType;
const actionUpdateData = getActionData(action);
emit('nodeTypeSelected', getNodeTypesWithManualTrigger(actionUpdateData.key));
setAddedNodeActionParameters(actionUpdateData, telemetry);
}
function addHttpNode() {
const updateData = {
name: '',
key: HTTP_REQUEST_NODE_TYPE,
value: {
authentication: "predefinedCredentialType",
},
} as IUpdateInformation;
emit('nodeTypeSelected', [MANUAL_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
setAddedNodeActionParameters(updateData, telemetry, false);
const app_identifier = state.activeNodeActions?.name;
$externalHooks().run('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier });
}
function onSubcategorySelected(subcategory: INodeCreateElement) {
state.isRoot = false;
state.selectedSubcategory = subcategory.key;
}
function onSubcategoryClose(activeSubcategories: INodeCreateElement[]) {
if(isActionsActive.value === true) setActiveActionsNodeType(null);
state.isRoot = activeSubcategories.length === 0;
state.selectedSubcategory = activeSubcategories[activeSubcategories.length - 1]?.key ?? '';
}
function shouldShowNodeActions(node: INodeCreateElement) {
if(isAppEventSubcategory.value) return true;
if(state.isRoot && !isSearchActive.value) return false;
// Do not show actions for core category when searching
if(node.type === 'node') return !node.properties.nodeType.codex?.categories?.includes(CORE_NODES_CATEGORY);
return false;
}
function trackActionsView() {
const trigger_action_count = selectedNodeActions.value
.filter((action) => action.name.toLowerCase().includes('trigger')).length;
const trackingPayload = {
app_identifier: state.activeNodeActions?.name,
actions: selectedNodeActions.value.map(action => action.displayName),
regular_action_count: selectedNodeActions.value.length - trigger_action_count,
trigger_action_count,
};
$externalHooks().run('nodeCreateList.onViewActions', trackingPayload);
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload);
}
const { isRoot, activeNodeActions } = toRefs(state);
</script>
<style lang="scss" module>

View File

@@ -1,6 +1,6 @@
<template>
<div class="type-selector" v-if="showTabs" data-test-id="node-creator-type-selector">
<el-tabs stretch :value="selectedType" @input="setType">
<div class="type-selector" v-if="nodeCreatorStore.showTabs" data-test-id="node-creator-type-selector">
<el-tabs stretch :value="nodeCreatorStore.selectedType" @input="nodeCreatorStore.setSelectedType">
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.all')" :name="ALL_NODE_FILTER"></el-tab-pane>
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.regular')" :name="REGULAR_NODE_FILTER"></el-tab-pane>
<el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.trigger')" :name="TRIGGER_NODE_FILTER"></el-tab-pane>
@@ -8,39 +8,11 @@
</div>
</template>
<script lang="ts">
<script setup lang="ts">
import { ALL_NODE_FILTER, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER } from '@/constants';
import { INodeFilterType } from '@/Interface';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { mapStores } from 'pinia';
import Vue from 'vue';
export default Vue.extend({
name: 'NodeCreateTypeSelector',
data() {
return {
REGULAR_NODE_FILTER,
TRIGGER_NODE_FILTER,
ALL_NODE_FILTER,
};
},
methods: {
setType(type: INodeFilterType) {
this.nodeCreatorStore.selectedType = type;
},
},
computed: {
...mapStores(
useNodeCreatorStore,
),
showTabs(): boolean {
return this.nodeCreatorStore.showTabs;
},
selectedType(): string {
return this.nodeCreatorStore.selectedType;
},
},
});
const nodeCreatorStore = useNodeCreatorStore();
</script>
<style lang="scss" scoped>
::v-deep .el-tabs__item {
@@ -58,9 +30,9 @@ export default Vue.extend({
.type-selector {
text-align: center;
background-color: $node-creator-select-background-color;
::v-deep .el-tabs > div {
margin-bottom: 0;
z-index: 1;
.el-tabs__nav {
height: 43px;