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:
@@ -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();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -56,6 +56,7 @@ export default Vue.extend({
|
||||
.subcategory {
|
||||
display: flex;
|
||||
padding: 11px 16px 11px 30px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.subcategoryWithIcon {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user