Files
Automata/packages/editor-ui/src/components/Node/NodeCreator/TriggerHelperPanel.vue
OlegIvaniv 79fe57dad8 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
2022-12-09 10:56:36 +01:00

394 lines
13 KiB
Vue

<template>
<div :class="{ [$style.triggerHelperContainer]: true, [$style.isRoot]: isRoot }">
<categorized-items
:expandAllCategories="isActionsActive"
:subcategoryOverride="nodeAppSubcategory"
:alwaysShowSearch="isActionsActive"
:categorizedItems="computedCategorizedItems"
:categoriesWithNodes="computedCategoriesWithNodes"
:initialActiveIndex="0"
:searchItems="searchItems"
: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"
/>
</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 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 { useNodeCreatorStore } from '@/stores/nodeCreator';
import { getCategoriesWithNodes, getCategorizedList } from "@/utils";
import { externalHooks } from '@/mixins/externalHooks';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { BaseTextKey } from '@/plugins/i18n';
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",
},
},
},
{
key: SCHEDULE_TRIGGER_NODE_TYPE,
type: "node",
properties: {
nodeType: {
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: 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>
.triggerHelperContainer {
height: 100%;
display: flex;
flex-direction: column;
// Remove node item border on the root level
&.isRoot {
--node-item-border: none;
}
}
.itemCreator {
height: calc(100% - 120px);
padding-top: 1px;
overflow-y: auto;
overflow-x: visible;
&::-webkit-scrollbar {
display: none;
}
}
.title {
font-size: var(--font-size-l);
line-height: var(--font-line-height-xloose);
font-weight: var(--font-weight-bold);
color: var(--color-text-dark);
padding: var(--spacing-s) var(--spacing-s) var(--spacing-3xs);
}
</style>