feat(editor): Enhance Node Creator actions view (#5954)

* WIP

* WIP

* Extract actions into composable

* WIP: Preserve categories when searching

* WIP

* WIP: Tweak styles

* WIP: Refactor node creator

* WIP: Finish Node Creator node view/subcategories refactor

* WIP: Finished actions refactor

* Cleanup & Lintfix

* WIP: Improve memory managment

* Fix interactions

* WIP

* WIP: Keyboard navigation

* Improve keyboard navigation and memory managment

* Finished view refactor

* FIx custom api calls and activation callouts

* Fix actions tracking and cleanup

* Product review fixes

* Telemetry fixes

* Fix node creator e2es

* Set action name font size and actionsEmpty font weight

* Fix failing credentials spec

* Make sure to select first action item when switching from nodes panel to actions panel

* Add actions panel e2e tests

* Cleanup

* Fix actions generation and cleanup

* Add correct Learn More link and adjust displaying of trigger icon

* Change trigger icon condition to use nodeType group

* Cleanup nodeTypesUtils and snapshots and lintfixes

* Lint fixes

* Refine logic to show trigger icon in node creator

* Add unit tests & clean up

* Add `003_auto_insert_action` experiment, hide empty sections for opposite root view

* Lintfix

* Do not show empty category tooltips and only show activation callout in triger root view

* Fix no-results node creator view

* Spacings tweaks and root rendering logic adjustment

* Add unit tests

* Lint and e2e fixes

* Revert CLI changes, fix unit tests

* Remove useless comments

* Sync master, replace $externalHooks mixin

* Lint fix

* Focus first action when panel slides in, not category

* Address PR comments

* Lint fix

* Remove `setAddedNodeActionParameters` optional track param

* Further simplify setAddedNodeActionParameters

* Fix pnpn lock file

* Fix types imports

* Fix 13-pinning spec
This commit is contained in:
OlegIvaniv
2023-04-26 09:18:10 +02:00
committed by GitHub
parent 6335e0938d
commit 390841bbf0
54 changed files with 3489 additions and 2450 deletions

View File

@@ -1,401 +1,52 @@
import { startCase } from 'lodash-es';
import { defineStore } from 'pinia';
import { STORES, TRIGGER_NODE_CREATOR_VIEW } from '@/constants';
import type {
INodePropertyCollection,
INodePropertyOptions,
IDataObject,
INodeProperties,
INodeTypeDescription,
INodeParameters,
INodeActionTypeDescription,
} from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import {
STORES,
MANUAL_TRIGGER_NODE_TYPE,
TRIGGER_NODE_FILTER,
STICKY_NODE_TYPE,
NODE_CREATOR_OPEN_SOURCES,
} from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useWorkflowsStore } from './workflows';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import type { INodeCreatorState, INodeFilterType, IUpdateInformation } from '@/Interface';
import { i18n } from '@/plugins/i18n';
import type { Telemetry } from '@/plugins/telemetry';
import { runExternalHook } from '@/utils';
import { useWebhooksStore } from '@/stores/webhooks';
NodeFilterType,
NodeCreatorOpenSource,
SimplifiedNodeType,
ActionsRecord,
} from '@/Interface';
const PLACEHOLDER_RECOMMENDED_ACTION_KEY = 'placeholder_recommended';
import { ref } from 'vue';
const customNodeActionsParsers: {
[key: string]: (
matchedProperty: INodeProperties,
nodeTypeDescription: INodeTypeDescription,
) => INodeActionTypeDescription[] | undefined;
} = {
['n8n-nodes-base.hubspotTrigger']: (matchedProperty, nodeTypeDescription) => {
const collection = matchedProperty?.options?.[0] as INodePropertyCollection;
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
const selectedView = ref<NodeFilterType>(TRIGGER_NODE_CREATOR_VIEW);
const mergedNodes = ref<SimplifiedNodeType[]>([]);
const actions = ref<ActionsRecord<typeof mergedNodes.value>>({});
return (collection?.values[0]?.options as INodePropertyOptions[])?.map(
(categoryItem): INodeActionTypeDescription => ({
...getNodeTypeBase(
nodeTypeDescription,
i18n.baseText('nodeCreator.actionsCategory.triggers'),
),
actionKey: categoryItem.value as string,
displayName: i18n.baseText('nodeCreator.actionsCategory.onEvent', {
interpolate: { event: startCase(categoryItem.name) },
}),
description: categoryItem.description || '',
displayOptions: matchedProperty.displayOptions,
values: { eventsUi: { eventValues: [{ name: categoryItem.value }] } },
}),
);
},
};
const showScrim = ref(false);
const openSource = ref<NodeCreatorOpenSource>('');
function filterActions(actions: INodeActionTypeDescription[]) {
// Do not show single action nodes
if (actions.length <= 1) return [];
return actions.filter(
(action: INodeActionTypeDescription, _: number, arr: INodeActionTypeDescription[]) => {
const isApiCall = action.actionKey === CUSTOM_API_CALL_KEY;
if (isApiCall) return false;
const isPlaceholderTriggerAction = action.actionKey === PLACEHOLDER_RECOMMENDED_ACTION_KEY;
return !isPlaceholderTriggerAction || (isPlaceholderTriggerAction && arr.length > 1);
},
);
}
function getNodeTypeBase(nodeTypeDescription: INodeTypeDescription, category: string) {
return {
name: nodeTypeDescription.name,
group: ['trigger'],
codex: {
categories: [category],
subcategories: {
[nodeTypeDescription.displayName]: [category],
},
},
iconUrl: nodeTypeDescription.iconUrl,
icon: nodeTypeDescription.icon,
version: [1],
defaults: {
...nodeTypeDescription.defaults,
},
inputs: [],
outputs: [],
properties: [],
};
}
function operationsCategory(
nodeTypeDescription: INodeTypeDescription,
): INodeActionTypeDescription[] {
if (!!nodeTypeDescription.properties.find((property) => property.name === 'resource')) return [];
const matchedProperty = nodeTypeDescription.properties.find(
(property) => property.name?.toLowerCase() === 'operation',
);
if (!matchedProperty || !matchedProperty.options) return [];
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
);
const items = filteredOutItems.map((item: INodePropertyOptions) => ({
...getNodeTypeBase(nodeTypeDescription, i18n.baseText('nodeCreator.actionsCategory.actions')),
actionKey: item.value as string,
displayName: item.action ?? startCase(item.name),
description: item.description ?? '',
displayOptions: matchedProperty.displayOptions,
values: {
[matchedProperty.name]: matchedProperty.type === 'multiOptions' ? [item.value] : item.value,
},
}));
// Do not return empty category
if (items.length === 0) return [];
return items;
}
function triggersCategory(nodeTypeDescription: INodeTypeDescription): INodeActionTypeDescription[] {
const matchingKeys = ['event', 'events', 'trigger on'];
const isTrigger = nodeTypeDescription.displayName?.toLowerCase().includes('trigger');
const matchedProperty = nodeTypeDescription.properties.find((property) =>
matchingKeys.includes(property.displayName?.toLowerCase()),
);
if (!isTrigger) return [];
// Inject placeholder action if no events are available
// so user is able to add node to the canvas from the actions panel
if (!matchedProperty || !matchedProperty.options) {
return [
{
...getNodeTypeBase(
nodeTypeDescription,
i18n.baseText('nodeCreator.actionsCategory.triggers'),
),
actionKey: PLACEHOLDER_RECOMMENDED_ACTION_KEY,
displayName: i18n.baseText('nodeCreator.actionsCategory.onNewEvent', {
interpolate: { event: nodeTypeDescription.displayName.replace('Trigger', '').trimEnd() },
}),
description: '',
},
];
function setMergeNodes(nodes: SimplifiedNodeType[]) {
mergedNodes.value = nodes;
}
const filteredOutItems = (matchedProperty.options as INodePropertyOptions[]).filter(
(categoryItem: INodePropertyOptions) => !['*', '', ' '].includes(categoryItem.name),
);
function setActions(nodes: ActionsRecord<typeof mergedNodes.value>) {
actions.value = nodes;
}
const customParsedItem = customNodeActionsParsers[nodeTypeDescription.name]?.(
matchedProperty,
nodeTypeDescription,
);
function setShowScrim(isVisible: boolean) {
showScrim.value = isVisible;
}
const items =
customParsedItem ??
filteredOutItems.map((categoryItem: INodePropertyOptions) => ({
...getNodeTypeBase(
nodeTypeDescription,
i18n.baseText('nodeCreator.actionsCategory.triggers'),
),
actionKey: categoryItem.value as string,
displayName:
categoryItem.action ??
i18n.baseText('nodeCreator.actionsCategory.onEvent', {
interpolate: { event: startCase(categoryItem.name) },
}),
description: categoryItem.description || '',
displayOptions: matchedProperty.displayOptions,
values: {
[matchedProperty.name]:
matchedProperty.type === 'multiOptions' ? [categoryItem.value] : categoryItem.value,
},
}));
function setSelectedView(view: NodeFilterType) {
selectedView.value = view;
}
return items;
}
function setOpenSource(view: NodeCreatorOpenSource) {
openSource.value = view;
}
function resourceCategories(
nodeTypeDescription: INodeTypeDescription,
): INodeActionTypeDescription[] {
const transformedNodes: INodeActionTypeDescription[] = [];
const matchedProperties = nodeTypeDescription.properties.filter(
(property) => property.displayName?.toLowerCase() === 'resource',
);
matchedProperties.forEach((property) => {
((property.options as INodePropertyOptions[]) || [])
.filter((option) => option.value !== CUSTOM_API_CALL_KEY)
.forEach((resourceOption, i, options) => {
const isSingleResource = options.length === 1;
// Match operations for the resource by checking if displayOptions matches or contains the resource name
const operations = nodeTypeDescription.properties.find(
(operation) =>
operation.name === 'operation' &&
(operation.displayOptions?.show?.resource?.includes(resourceOption.value) ||
isSingleResource),
);
if (!operations?.options) return;
const items = ((operations.options as INodePropertyOptions[]) || []).map(
(operationOption) => {
const displayName =
operationOption.action ?? `${resourceOption.name} ${startCase(operationOption.name)}`;
// We need to manually populate displayOptions as they are not present in the node description
// if the resource has only one option
const displayOptions = isSingleResource
? { show: { resource: [(options as INodePropertyOptions[])[0]?.value] } }
: operations?.displayOptions;
return {
...getNodeTypeBase(nodeTypeDescription, resourceOption.name),
actionKey: operationOption.value as string,
description: operationOption?.description ?? '',
displayOptions,
values: {
operation:
operations?.type === 'multiOptions'
? [operationOption.value]
: operationOption.value,
},
displayName,
group: ['trigger'],
};
},
);
transformedNodes.push(...items);
});
});
return transformedNodes;
}
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, {
state: (): INodeCreatorState => ({
itemsFilter: '',
showScrim: false,
selectedView: TRIGGER_NODE_FILTER,
rootViewHistory: [],
openSource: '',
}),
actions: {
setShowScrim(isVisible: boolean) {
this.showScrim = isVisible;
},
setSelectedView(selectedNodeType: INodeFilterType) {
this.selectedView = selectedNodeType;
if (!this.rootViewHistory.includes(selectedNodeType)) {
this.rootViewHistory.push(selectedNodeType);
}
},
closeCurrentView() {
this.rootViewHistory.pop();
this.selectedView = this.rootViewHistory[this.rootViewHistory.length - 1];
},
resetRootViewHistory() {
this.rootViewHistory = [];
},
setFilter(search: string) {
this.itemsFilter = search;
},
setAddedNodeActionParameters(action: IUpdateInformation, telemetry?: Telemetry, track = true) {
const { $onAction: onWorkflowStoreAction } = useWorkflowsStore();
const storeWatcher = onWorkflowStoreAction(
({ name, after, store: { setLastNodeParameters }, args }) => {
if (name !== 'addNode' || args[0].type !== action.key) return;
after(() => {
setLastNodeParameters(action);
if (track) this.trackActionSelected(action, telemetry);
storeWatcher();
});
},
);
return storeWatcher;
},
trackActionSelected(action: IUpdateInformation, telemetry?: Telemetry) {
const payload = {
node_type: action.key,
action: action.name,
resource: (action.value as INodeParameters).resource || '',
};
runExternalHook('nodeCreateList.addAction', useWebhooksStore(), payload);
telemetry?.trackNodesPanel('nodeCreateList.addAction', payload);
},
},
getters: {
visibleNodesWithActions(): INodeTypeDescription[] {
const nodes = deepCopy(useNodeTypesStore().visibleNodeTypes);
const nodesWithActions = nodes.map((node) => {
node.actions = [
...triggersCategory(node),
...operationsCategory(node),
...resourceCategories(node),
];
return node;
});
return nodesWithActions;
},
mergedAppNodes(): INodeTypeDescription[] {
const triggers = this.visibleNodesWithActions.filter((node) =>
node.group.includes('trigger'),
);
const apps = this.visibleNodesWithActions
.filter((node) => !node.group.includes('trigger'))
.map((node) => {
const newNode = deepCopy(node);
newNode.actions = newNode.actions || [];
return newNode;
});
triggers.forEach((node) => {
const normalizedName = node.name.toLowerCase().replace('trigger', '');
const app = apps.find((node) => node.name.toLowerCase() === normalizedName);
const newNode = deepCopy(node);
if (app && app.actions?.length) {
// merge triggers into regular nodes that match
app?.actions?.push(...(newNode.actions || []));
app.description = newNode.description; // default to trigger description
} else {
newNode.actions = newNode.actions || [];
apps.push(newNode);
}
});
const filteredNodes = apps.map((node) => ({
...node,
actions: filterActions(node.actions || []),
}));
return filteredNodes;
},
getNodeTypesWithManualTrigger:
() =>
(nodeType?: string): string[] => {
if (!nodeType) return [];
const { workflowTriggerNodes } = useWorkflowsStore();
const isTrigger = useNodeTypesStore().isTriggerNode(nodeType);
const workflowContainsTrigger = workflowTriggerNodes.length > 0;
const isTriggerPanel = useNodeCreatorStore().selectedView === TRIGGER_NODE_FILTER;
const isStickyNode = nodeType === STICKY_NODE_TYPE;
const singleNodeOpenSources = [
NODE_CREATOR_OPEN_SOURCES.PLUS_ENDPOINT,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_ACTION,
NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
];
// If the node creator was opened from the plus endpoint, node connection action, or node connection drop
// then we do not want to append the manual trigger
const isSingleNodeOpenSource = singleNodeOpenSources.includes(
useNodeCreatorStore().openSource,
);
const shouldAppendManualTrigger =
!isSingleNodeOpenSource &&
!isTrigger &&
!workflowContainsTrigger &&
isTriggerPanel &&
!isStickyNode;
const nodeTypes = shouldAppendManualTrigger
? [MANUAL_TRIGGER_NODE_TYPE, nodeType]
: [nodeType];
return nodeTypes;
},
getActionData:
() =>
(actionItem: INodeActionTypeDescription): IUpdateInformation => {
const displayOptions = actionItem.displayOptions;
const displayConditions = Object.keys(displayOptions?.show || {}).reduce(
(acc: IDataObject, showCondition: string) => {
acc[showCondition] = displayOptions?.show?.[showCondition]?.[0];
return acc;
},
{},
);
return {
name: actionItem.displayName,
key: actionItem.name as string,
value: { ...actionItem.values, ...displayConditions } as INodeParameters,
};
},
},
return {
openSource,
selectedView,
showScrim,
mergedNodes,
actions,
setShowScrim,
setSelectedView,
setOpenSource,
setActions,
setMergeNodes,
};
});

View File

@@ -6,14 +6,9 @@ import {
getResourceLocatorResults,
} from '@/api/nodeTypes';
import { DEFAULT_NODETYPE_VERSION, STORES } from '@/constants';
import type {
ICategoriesWithNodes,
INodeCreateElement,
INodeTypesState,
IResourceLocatorReqParams,
} from '@/Interface';
import type { INodeTypesState, IResourceLocatorReqParams } from '@/Interface';
import { addHeaders, addNodeTranslation } from '@/plugins/i18n';
import { omit, getCategoriesWithNodes, getCategorizedList } from '@/utils';
import { omit } from '@/utils';
import type {
ILoadOptions,
INodeCredentials,
@@ -27,8 +22,7 @@ import { defineStore } from 'pinia';
import Vue from 'vue';
import { useCredentialsStore } from './credentials';
import { useRootStore } from './n8nRootStore';
import { useUsersStore } from './users';
import { useNodeCreatorStore } from './nodeCreator';
function getNodeVersions(nodeType: INodeTypeDescription) {
return Array.isArray(nodeType.version) ? nodeType.version : [nodeType.version];
}
@@ -88,13 +82,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
visibleNodeTypes(): INodeTypeDescription[] {
return this.allLatestNodeTypes.filter((nodeType: INodeTypeDescription) => !nodeType.hidden);
},
categoriesWithNodes(): ICategoriesWithNodes {
const usersStore = useUsersStore();
return getCategoriesWithNodes(this.visibleNodeTypes, usersStore.personalizedNodeTypes);
},
categorizedItems(): INodeCreateElement[] {
return getCategorizedList(this.categoriesWithNodes);
},
},
actions: {
setNodeTypes(newNodeTypes: INodeTypeDescription[] = []): void {
@@ -128,9 +115,6 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, {
{ ...this.nodeTypes },
);
Vue.set(this, 'nodeTypes', nodeTypes);
// Trigger compute of mergedAppNodes getter so it's ready when user opens the node creator
useNodeCreatorStore().mergedAppNodes;
},
removeNodeTypes(nodeTypesToRemove: INodeTypeDescription[]): void {
this.nodeTypes = nodeTypesToRemove.reduce(