feat(editor): Unify regular and trigger node creator panels (#5315)

* WIP: Merge TriggerHelperPanel with MainPanel

* WIP: Implement switching between views

* Remove logging

* WIP: Rework search

* Fix category toggling and search results display

* Fix node item description

* Sort actions based on the root view

* Adjust personalisation modal, make trigger canvas node round

* Linting fixes

* Fix filtering of API options

* Fix types and no result state

* Cleanup

* Linting fixes

* Adjust mode prop for node creator tracking

* Fix merging of core nodes and filtering of single placeholder actions

* Lint fixes

* Implement actions override, fix node creator view item spacing and increase click radius of trigger node icon

* Fix keyboard view navigation

* WIP: E2E Tests

* Address product review

* Minor fixes & cleanup

* Fix tests

* Some more test fixes

* Add specs to check actions and panels

* Update personalisation survey snapshot
This commit is contained in:
OlegIvaniv
2023-02-17 15:08:26 +01:00
committed by GitHub
parent 561882f599
commit 9a1e7b52f7
49 changed files with 1187 additions and 1339 deletions

View File

@@ -1,100 +1,408 @@
<template>
<div class="container" ref="mainPanelContainer">
<div class="main-panel">
<trigger-helper-panel
v-if="nodeCreatorStore.selectedType === TRIGGER_NODE_FILTER"
@nodeTypeSelected="$listeners.nodeTypeSelected"
>
<template #header>
<type-selector />
</template>
</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]"
:allItems="categorizedItems"
@nodeTypeSelected="$listeners.nodeTypeSelected"
@actionsOpen="() => {}"
>
<template #header>
<type-selector />
</template>
</categorized-items>
</div>
<div :class="{ [$style.mainPanel]: true, [$style.isRoot]: isRoot }">
<CategorizedItems
:subcategoryOverride="nodeAppSubcategory"
:alwaysShowSearch="isActionsActive"
:hideOtherCategoryItems="isActionsActive"
:categorizedItems="computedCategorizedItems"
:searchItems="searchItems"
:withActionsGetter="shouldShowNodeActions"
:withDescriptionGetter="shouldShowNodeDescription"
:firstLevelItems="firstLevelItems"
:showSubcategoryIcon="isActionsActive"
:allItems="transformCreateElements(mergedAppNodes)"
:searchPlaceholder="searchPlaceholder"
@subcategoryClose="onSubcategoryClose"
@onSubcategorySelected="onSubcategorySelected"
@nodeTypeSelected="onNodeTypeSelected"
@actionsOpen="setActiveActionsNodeType"
@actionSelected="onActionSelected"
>
<template #noResults>
<no-results
data-test-id="categorized-no-results"
:showRequest="!isActionsActive"
:show-icon="!isActionsActive"
>
<template #title v-if="!isActionsActive">
<p v-text="$locale.baseText('nodeCreator.noResults.weDidntMakeThatYet')" />
</template>
<template v-if="isActionsActive" #action>
<p
v-if="containsAPIAction"
v-html="getCustomAPICallHintLocale('apiCallNoResult')"
class="clickable"
@click.stop="addHttpNode(true)"
/>
<p v-else v-text="$locale.baseText('nodeCreator.noResults.noMatchingActions')" />
</template>
<template v-else #action>
{{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
<n8n-link v-if="[REGULAR_NODE_FILTER].includes(selectedView)" @click="addHttpNode">
{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}
</n8n-link>
<n8n-link v-if="[TRIGGER_NODE_FILTER].includes(selectedView)" @click="addWebHookNode()">
{{ $locale.baseText('nodeCreator.noResults.webhook') }}
</n8n-link>
{{ $locale.baseText('nodeCreator.noResults.node') }}
</template>
</no-results>
</template>
<template #header>
<p
v-if="isRoot && activeView && activeView.title"
v-text="activeView.title"
:class="$style.title"
/>
</template>
<template #description>
<p
v-if="isRoot && activeView && activeView.description"
v-text="activeView.description"
:class="$style.description"
/>
</template>
<template #footer v-if="activeNodeActions && containsAPIAction">
<span
v-html="getCustomAPICallHintLocale('apiCall')"
class="clickable"
@click.stop="addHttpNode(true)"
/>
</template>
</CategorizedItems>
</div>
</template>
<script setup lang="ts">
import { watch, getCurrentInstance, onMounted, onUnmounted } from 'vue';
import { externalHooks } from '@/mixins/externalHooks';
import TriggerHelperPanel from './TriggerHelperPanel.vue';
import { reactive, toRefs, getCurrentInstance, computed, onUnmounted, ref } from 'vue';
import {
INodeTypeDescription,
INodeActionTypeDescription,
INodeTypeNameVersion,
} from 'n8n-workflow';
import {
INodeCreateElement,
NodeCreateElement,
IActionItemProps,
SubcategoryCreateElement,
IUpdateInformation,
} from '@/Interface';
import {
ALL_NODE_FILTER,
TRIGGER_NODE_FILTER,
OTHER_TRIGGER_NODES_SUBCATEGORY,
CORE_NODES_CATEGORY,
WEBHOOK_NODE_TYPE,
EMAIL_IMAP_NODE_TYPE,
CUSTOM_API_CALL_NAME,
HTTP_REQUEST_NODE_TYPE,
STICKY_NODE_TYPE,
REGULAR_NODE_FILTER,
TRIGGER_NODE_FILTER,
N8N_NODE_TYPE,
} from '@/constants';
import CategorizedItems from './CategorizedItems.vue';
import TypeSelector from './TypeSelector.vue';
import { INodeCreateElement } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNodeCreatorStore } from '@/stores/nodeCreator';
import { getCategoriesWithNodes, getCategorizedList } from '@/utils';
import { externalHooks } from '@/mixins/externalHooks';
import { useNodeTypesStore } from '@/stores/nodeTypes';
export interface Props {
searchItems?: INodeCreateElement[];
}
withDefaults(defineProps<Props>(), {
searchItems: () => [],
});
import { BaseTextKey } from '@/plugins/i18n';
import NoResults from './NoResults.vue';
import { useRootStore } from '@/stores/n8nRootStore';
import useMainPanelView from './useMainPanelView';
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,
});
},
const emit = defineEmits({
nodeTypeSelected: (nodeTypes: string[]) => true,
});
const state = reactive({
isRoot: true,
selectedSubcategory: '',
activeNodeActions: null as INodeTypeDescription | null,
});
const { baseUrl } = useRootStore();
const { $externalHooks } = new externalHooks();
const {
mergedAppNodes,
getActionData,
getNodeTypesWithManualTrigger,
setAddedNodeActionParameters,
} = useNodeCreatorStore();
const { activeView } = useMainPanelView();
const telemetry = instance?.proxy.$telemetry;
const { isTriggerNode } = useNodeTypesStore();
const containsAPIAction = computed(
() =>
state.activeNodeActions?.properties.some((p) =>
p.options?.find((o) => o.name === CUSTOM_API_CALL_NAME),
) === true,
);
onMounted(() => {
$externalHooks().run('nodeCreateList.mounted');
// Make sure tabs are visible on mount
nodeCreatorStore.setShowTabs(true);
const selectedView = computed(() => useNodeCreatorStore().selectedView);
const computedCategorizedItems = computed(() => {
if (isActionsActive.value) {
return sortActions(getCategorizedList(computedCategoriesWithNodes.value, true));
}
return getCategorizedList(computedCategoriesWithNodes.value, true);
});
const nodeAppSubcategory = computed<SubcategoryCreateElement | undefined>(() => {
if (!state.activeNodeActions) return undefined;
const icon = state.activeNodeActions.iconUrl
? `${baseUrl}${state.activeNodeActions.iconUrl}`
: state.activeNodeActions.icon?.split(':')[1];
return {
type: 'subcategory',
key: state.activeNodeActions.name,
properties: {
subcategory: state.activeNodeActions.displayName,
description: '',
iconType: state.activeNodeActions.iconUrl ? 'file' : 'icon',
icon,
color: state.activeNodeActions.defaults.color,
},
};
});
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 isTrigger = isTriggerNode(node.name);
const isRegularNode = !isTrigger;
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;
// Never show core nodes and sticky node in the Apps subcategory
if (isCoreNode || isStickyNode) return false;
// Only show nodes without action within their view
if (!hasActions) {
return isRegularNode
? selectedView.value === REGULAR_NODE_FILTER
: selectedView.value === TRIGGER_NODE_FILTER;
}
return true;
});
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 ? activeView.value.items : []));
const searchItems = computed<INodeCreateElement[]>(() => {
return state.activeNodeActions
? transformCreateElements(selectedNodeActions.value)
: transformCreateElements(filteredMergedAppNodes.value);
});
// If the user is in the root view, we want to show trigger nodes first
// otherwise we want to show them last
function sortActions(nodeCreateElements: INodeCreateElement[]): INodeCreateElement[] {
const elements = {
trigger: [] as INodeCreateElement[],
regular: [] as INodeCreateElement[],
};
nodeCreateElements.forEach((el) => {
const isTriggersCategory = el.type === 'category' && el.key === 'Triggers';
const isTriggerAction = el.type === 'action' && el.category === 'Triggers';
elements[isTriggersCategory || isTriggerAction ? 'trigger' : 'regular'].push(el);
});
if (selectedView.value === TRIGGER_NODE_FILTER) {
return [...elements.trigger, ...elements.regular];
}
return [...elements.regular, ...elements.trigger];
}
function transformCreateElements(
createElements: Array<INodeTypeDescription | INodeActionTypeDescription>,
): INodeCreateElement[] {
const sorted = [...createElements];
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) => {
// N8n node is a special case since it's the only core node that is both trigger and regular
// if we have more cases like this we should add more robust logic
const isN8nNode = nodeType.name.includes(N8N_NODE_TYPE);
return {
type: 'node',
category: nodeType.codex?.categories,
key: nodeType.name,
properties: {
nodeType,
subcategory: state.activeNodeActions?.displayName ?? '',
},
includedByTrigger: isN8nNode || nodeType.group.includes('trigger'),
includedByRegular: isN8nNode || !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 },
});
}
function setActiveActionsNodeType(nodeType: INodeTypeDescription | null) {
state.activeNodeActions = nodeType;
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 addWebHookNode() {
emit('nodeTypeSelected', [WEBHOOK_NODE_TYPE]);
}
function addHttpNode(isAction: boolean) {
const updateData = {
name: '',
key: HTTP_REQUEST_NODE_TYPE,
value: {
authentication: 'predefinedCredentialType',
},
} as IUpdateInformation;
emit('nodeTypeSelected', [HTTP_REQUEST_NODE_TYPE]);
if (isAction) {
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 shouldShowNodeDescription(node: NodeCreateElement) {
return (node.category || []).includes(CORE_NODES_CATEGORY);
}
function shouldShowNodeActions(node: INodeCreateElement) {
if (state.isRoot && useNodeCreatorStore().itemsFilter === '') return false;
return true;
}
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);
}
onUnmounted(() => {
nodeCreatorStore.setSelectedType(ALL_NODE_FILTER);
$externalHooks().run('nodeCreateList.destroyed');
instance?.proxy.$telemetry.trackNodesPanel('nodeCreateList.destroyed', {
workflow_id: workflowId,
});
useNodeCreatorStore().resetRootViewHistory();
});
const { isRoot, activeNodeActions } = toRefs(state);
</script>
<style lang="scss" scoped>
.container {
<style lang="scss" module>
.mainPanel {
--node-icon-color: var(--color-text-base);
height: 100%;
display: flex;
flex-direction: column;
// Remove node item border on the root level
&.isRoot {
--node-item-border: none;
}
}
.main-panel {
height: 100%;
.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);
}
.description {
font-size: var(--font-size-s);
line-height: var(--font-line-height-loose);
color: var(--color-text-base);
}
</style>