Merge branch 'master' of https://github.com/n8n-io/n8n into executions-count-component

This commit is contained in:
Michael Kret 2024-11-01 08:13:14 +02:00
commit 29b1b25924
33 changed files with 878 additions and 332 deletions

View file

@ -90,6 +90,14 @@ function createRunDataWithError(inputMessage: string) {
routine: 'InitPostgres', routine: 'InitPostgres',
} as unknown as Error, } as unknown as Error,
} as ExecutionError, } as ExecutionError,
metadata: {
subRun: [
{
node: 'Postgres Chat Memory',
runIndex: 0,
},
],
},
}), }),
createMockNodeExecutionData(AGENT_NODE_NAME, { createMockNodeExecutionData(AGENT_NODE_NAME, {
executionStatus: 'error', executionStatus: 'error',
@ -124,14 +132,6 @@ function createRunDataWithError(inputMessage: string) {
description: 'Internal error', description: 'Internal error',
message: 'Internal error', message: 'Internal error',
} as unknown as ExecutionError, } as unknown as ExecutionError,
metadata: {
subRun: [
{
node: 'Postgres Chat Memory',
runIndex: 0,
},
],
},
}), }),
]; ];
} }

View file

@ -278,6 +278,9 @@ describe('Langchain Integration', () => {
}, },
}, },
}, },
metadata: {
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
},
inputOverride: { inputOverride: {
ai_languageModel: [ ai_languageModel: [
[ [
@ -316,9 +319,6 @@ describe('Langchain Integration', () => {
jsonData: { jsonData: {
main: { output: 'Hi there! How can I assist you today?' }, main: { output: 'Hi there! How can I assist you today?' },
}, },
metadata: {
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
},
}), }),
], ],
lastNodeExecuted: AGENT_NODE_NAME, lastNodeExecuted: AGENT_NODE_NAME,

View file

@ -20,6 +20,10 @@ const modelField: INodeProperties = {
type: 'options', type: 'options',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [ options: [
{
name: 'Claude 3.5 Sonnet(20241022)',
value: 'claude-3-5-sonnet-20241022',
},
{ {
name: 'Claude 3 Opus(20240229)', name: 'Claude 3 Opus(20240229)',
value: 'claude-3-opus-20240229', value: 'claude-3-opus-20240229',

View file

@ -63,7 +63,7 @@ export class ToolVectorStore implements INodeType {
name: 'name', name: 'name',
type: 'string', type: 'string',
default: '', default: '',
placeholder: 'e.g. state_of_union_address', placeholder: 'e.g. company_knowledge_base',
validateType: 'string-alphanumeric', validateType: 'string-alphanumeric',
description: 'Name of the vector store', description: 'Name of the vector store',
}, },
@ -72,7 +72,7 @@ export class ToolVectorStore implements INodeType {
name: 'description', name: 'description',
type: 'string', type: 'string',
default: '', default: '',
placeholder: 'The most recent state of the Union address', placeholder: 'Retrieves data about [insert information about your data here]...',
typeOptions: { typeOptions: {
rows: 3, rows: 3,
}, },

View file

@ -65,6 +65,42 @@ describe('Publisher', () => {
JSON.stringify({ ...msg, senderId: hostId, selfSend: false, debounce: true }), JSON.stringify({ ...msg, senderId: hostId, selfSend: false, debounce: true }),
); );
}); });
it('should not debounce `add-webhooks-triggers-and-pollers`', async () => {
const publisher = new Publisher(logger, redisClientService, instanceSettings);
const msg = mock<PubSub.Command>({ command: 'add-webhooks-triggers-and-pollers' });
await publisher.publishCommand(msg);
expect(client.publish).toHaveBeenCalledWith(
'n8n.commands',
JSON.stringify({
...msg,
_isMockObject: true,
senderId: hostId,
selfSend: true,
debounce: false,
}),
);
});
it('should not debounce `remove-triggers-and-pollers`', async () => {
const publisher = new Publisher(logger, redisClientService, instanceSettings);
const msg = mock<PubSub.Command>({ command: 'remove-triggers-and-pollers' });
await publisher.publishCommand(msg);
expect(client.publish).toHaveBeenCalledWith(
'n8n.commands',
JSON.stringify({
...msg,
_isMockObject: true,
senderId: hostId,
selfSend: true,
debounce: false,
}),
);
});
}); });
describe('publishWorkerResponse', () => { describe('publishWorkerResponse', () => {

View file

@ -1,3 +1,5 @@
import type { PubSub } from './pubsub/pubsub.types';
export const QUEUE_NAME = 'jobs'; export const QUEUE_NAME = 'jobs';
export const JOB_TYPE_NAME = 'job'; export const JOB_TYPE_NAME = 'job';
@ -11,7 +13,7 @@ export const WORKER_RESPONSE_PUBSUB_CHANNEL = 'n8n.worker-response';
/** /**
* Commands that should be sent to the sender as well, e.g. during workflow activation and * Commands that should be sent to the sender as well, e.g. during workflow activation and
* deactivation in multi-main setup. */ * deactivation in multi-main setup. */
export const SELF_SEND_COMMANDS = new Set([ export const SELF_SEND_COMMANDS = new Set<PubSub.Command['command']>([
'add-webhooks-triggers-and-pollers', 'add-webhooks-triggers-and-pollers',
'remove-triggers-and-pollers', 'remove-triggers-and-pollers',
]); ]);
@ -20,7 +22,8 @@ export const SELF_SEND_COMMANDS = new Set([
* Commands that should not be debounced when received, e.g. during webhook handling in * Commands that should not be debounced when received, e.g. during webhook handling in
* multi-main setup. * multi-main setup.
*/ */
export const IMMEDIATE_COMMANDS = new Set([ export const IMMEDIATE_COMMANDS = new Set<PubSub.Command['command']>([
'add-webhooks-triggers-and-pollers', 'add-webhooks-triggers-and-pollers',
'remove-triggers-and-pollers',
'relay-execution-lifecycle-event', 'relay-execution-lifecycle-event',
]); ]);

View file

@ -29,6 +29,7 @@ import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue'
import type { IDataObject } from 'n8n-workflow'; import type { IDataObject } from 'n8n-workflow';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
const emit = defineEmits<{ const emit = defineEmits<{
nodeTypeSelected: [value: [actionKey: string, nodeName: string] | [nodeName: string]]; nodeTypeSelected: [value: [actionKey: string, nodeName: string] | [nodeName: string]];
@ -47,6 +48,8 @@ const {
actionsCategoryLocales, actionsCategoryLocales,
} = useActions(); } = useActions();
const nodeCreatorStore = useNodeCreatorStore();
// We only inject labels if search is empty // We only inject labels if search is empty
const parsedTriggerActions = computed(() => const parsedTriggerActions = computed(() =>
parseActions(actions.value, actionsCategoryLocales.value.triggers, false), parseActions(actions.value, actionsCategoryLocales.value.triggers, false),
@ -182,7 +185,7 @@ function trackActionsView() {
}; };
void useExternalHooks().run('nodeCreateList.onViewActions', trackingPayload); void useExternalHooks().run('nodeCreateList.onViewActions', trackingPayload);
telemetry?.trackNodesPanel('nodeCreateList.onViewActions', trackingPayload); nodeCreatorStore.onViewActions(trackingPayload);
} }
function resetSearch() { function resetSearch() {
@ -206,7 +209,7 @@ function addHttpNode() {
void useExternalHooks().run('nodeCreateList.onActionsCustmAPIClicked', { void useExternalHooks().run('nodeCreateList.onActionsCustmAPIClicked', {
app_identifier, app_identifier,
}); });
telemetry?.trackNodesPanel('nodeCreateList.onActionsCustmAPIClicked', { app_identifier }); nodeCreatorStore.onActionsCustomAPIClicked({ app_identifier });
} }
// Anonymous component to handle triggers and actions rendering order // Anonymous component to handle triggers and actions rendering order

View file

@ -23,7 +23,6 @@ import ItemsRenderer from '../Renderers/ItemsRenderer.vue';
import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue'; import CategorizedItemsRenderer from '../Renderers/CategorizedItemsRenderer.vue';
import NoResults from '../Panel/NoResults.vue'; import NoResults from '../Panel/NoResults.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils'; import { getNodeIcon, getNodeIconColor, getNodeIconUrl } from '@/utils/nodeTypesUtils';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
@ -36,11 +35,10 @@ const emit = defineEmits<{
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
const telemetry = useTelemetry();
const uiStore = useUIStore(); const uiStore = useUIStore();
const rootStore = useRootStore(); const rootStore = useRootStore();
const { mergedNodes, actions } = useNodeCreatorStore(); const { mergedNodes, actions, onSubcategorySelected } = useNodeCreatorStore();
const { pushViewStack, popViewStack } = useViewStacks(); const { pushViewStack, popViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation(); const { registerKeyHook } = useKeyboardNavigation();
@ -83,7 +81,7 @@ function onSelected(item: INodeCreateElement) {
sections: item.properties.sections, sections: item.properties.sections,
}); });
telemetry.trackNodesPanel('nodeCreateList.onSubcategorySelected', { onSubcategorySelected({
subcategory: item.key, subcategory: item.key,
}); });
} }
@ -153,9 +151,6 @@ function onSelected(item: INodeCreateElement) {
if (item.type === 'link') { if (item.type === 'link') {
window.open(item.properties.url, '_blank'); window.open(item.properties.url, '_blank');
telemetry.trackNodesPanel('nodeCreateList.onLinkSelected', {
link: item.properties.url,
});
} }
} }

View file

@ -17,12 +17,15 @@ import SearchBar from './SearchBar.vue';
import ActionsRenderer from '../Modes/ActionsMode.vue'; import ActionsRenderer from '../Modes/ActionsMode.vue';
import NodesRenderer from '../Modes/NodesMode.vue'; import NodesRenderer from '../Modes/NodesMode.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce';
const i18n = useI18n(); const i18n = useI18n();
const { callDebounced } = useDebounce();
const { mergedNodes } = useNodeCreatorStore(); const { mergedNodes } = useNodeCreatorStore();
const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks(); const { pushViewStack, popViewStack, updateCurrentViewStack } = useViewStacks();
const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation(); const { setActiveItemIndex, attachKeydownEvent, detachKeydownEvent } = useKeyboardNavigation();
const nodeCreatorStore = useNodeCreatorStore();
const activeViewStack = computed(() => useViewStacks().activeViewStack); const activeViewStack = computed(() => useViewStacks().activeViewStack);
@ -55,6 +58,19 @@ function onSearch(value: string) {
if (activeViewStack.value.uuid) { if (activeViewStack.value.uuid) {
updateCurrentViewStack({ search: value }); updateCurrentViewStack({ search: value });
void setActiveItemIndex(getDefaultActiveIndex(value)); void setActiveItemIndex(getDefaultActiveIndex(value));
if (value.length) {
callDebounced(
nodeCreatorStore.onNodeFilterChanged,
{ trailing: true, debounceTime: 2000 },
{
newValue: value,
filteredNodes: activeViewStack.value.items ?? [],
filterMode: activeViewStack.value.rootView ?? 'Regular',
subcategory: activeViewStack.value.subcategory,
title: activeViewStack.value.title,
},
);
}
} }
} }
@ -299,6 +315,7 @@ function onBackButton() {
margin-top: var(--spacing-4xs); margin-top: var(--spacing-4xs);
font-size: var(--font-size-s); font-size: var(--font-size-s);
line-height: 19px; line-height: 19px;
color: var(--color-text-base); color: var(--color-text-base);
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
} }

View file

@ -8,7 +8,7 @@ import { useKeyboardNavigation } from '../composables/useKeyboardNavigation';
import { useViewStacks } from '../composables/useViewStacks'; import { useViewStacks } from '../composables/useViewStacks';
import ItemsRenderer from './ItemsRenderer.vue'; import ItemsRenderer from './ItemsRenderer.vue';
import CategoryItem from '../ItemTypes/CategoryItem.vue'; import CategoryItem from '../ItemTypes/CategoryItem.vue';
import { useTelemetry } from '@/composables/useTelemetry'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
export interface Props { export interface Props {
elements: INodeCreateElement[]; elements: INodeCreateElement[];
@ -24,10 +24,10 @@ const props = withDefaults(defineProps<Props>(), {
elements: () => [], elements: () => [],
}); });
const telemetry = useTelemetry();
const { popViewStack } = useViewStacks(); const { popViewStack } = useViewStacks();
const { registerKeyHook } = useKeyboardNavigation(); const { registerKeyHook } = useKeyboardNavigation();
const { workflowId } = useWorkflowsStore(); const { workflowId } = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId); const activeItemId = computed(() => useKeyboardNavigation()?.activeItemId);
const actionCount = computed(() => props.elements.filter(({ type }) => type === 'action').length); const actionCount = computed(() => props.elements.filter(({ type }) => type === 'action').length);
@ -38,10 +38,11 @@ function toggleExpanded() {
} }
function setExpanded(isExpanded: boolean) { function setExpanded(isExpanded: boolean) {
const prev = expanded.value;
expanded.value = isExpanded; expanded.value = isExpanded;
if (expanded.value) { if (expanded.value && !prev) {
telemetry.trackNodesPanel('nodeCreateList.onCategoryExpanded', { nodeCreatorStore.onCategoryExpanded({
category_name: props.category, category_name: props.category,
workflow_id: workflowId, workflow_id: workflowId,
}); });

View file

@ -332,7 +332,11 @@ export const useActions = () => {
return storeWatcher; return storeWatcher;
} }
function trackActionSelected(action: IUpdateInformation, telemetry: Telemetry, rootView: string) { function trackActionSelected(
action: IUpdateInformation,
_telemetry: Telemetry,
rootView: string,
) {
const payload = { const payload = {
node_type: action.key, node_type: action.key,
action: action.name, action: action.name,
@ -340,7 +344,7 @@ export const useActions = () => {
resource: (action.value as INodeParameters).resource || '', resource: (action.value as INodeParameters).resource || '',
}; };
void useExternalHooks().run('nodeCreateList.addAction', payload); void useExternalHooks().run('nodeCreateList.addAction', payload);
telemetry?.trackNodesPanel('nodeCreateList.addAction', payload); useNodeCreatorStore().onAddActions(payload);
} }
return { return {

View file

@ -100,14 +100,15 @@ const isTriggerNode = computed(() => {
}); });
const hasAiMetadata = computed(() => { const hasAiMetadata = computed(() => {
if (isNodeRunning.value || !workflowRunData.value) {
return false;
}
if (node.value) { if (node.value) {
const resultData = workflowsStore.getWorkflowResultDataByNodeName(node.value.name); const connectedSubNodes = props.workflow.getParentNodes(node.value.name, 'ALL_NON_MAIN');
const resultData = connectedSubNodes.map(workflowsStore.getWorkflowResultDataByNodeName);
if (!resultData || !Array.isArray(resultData) || resultData.length === 0) { return resultData && Array.isArray(resultData) && resultData.length > 0;
return false;
}
return !!resultData[resultData.length - 1].metadata;
} }
return false; return false;
}); });
@ -295,6 +296,7 @@ const activatePane = () => {
:block-u-i="blockUI" :block-u-i="blockUI"
:is-production-execution-preview="isProductionExecutionPreview" :is-production-execution-preview="isProductionExecutionPreview"
:is-pane-active="isPaneActive" :is-pane-active="isPaneActive"
:hide-pagination="outputMode === 'logs'"
pane-type="output" pane-type="output"
:data-output-type="outputMode" :data-output-type="outputMode"
@activate-pane="activatePane" @activate-pane="activatePane"
@ -368,7 +370,7 @@ const activatePane = () => {
</template> </template>
<template v-if="outputMode === 'logs' && node" #content> <template v-if="outputMode === 'logs' && node" #content>
<RunDataAi :node="node" :run-index="runIndex" /> <RunDataAi :node="node" :run-index="runIndex" :workflow="workflow" />
</template> </template>
<template #recovered-artificial-output-data> <template #recovered-artificial-output-data>

View file

@ -162,6 +162,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
hidePagination: {
type: Boolean,
default: false,
},
}, },
setup(props) { setup(props) {
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
@ -1743,6 +1747,7 @@ export default defineComponent({
</div> </div>
<div <div
v-if=" v-if="
hidePagination === false &&
hasNodeRun && hasNodeRun &&
!hasRunError && !hasRunError &&
displayMode !== 'binary' && displayMode !== 'binary' &&

View file

@ -1,8 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import type { ITaskSubRunMetadata, ITaskDataConnections } from 'n8n-workflow'; import type { ITaskDataConnections, NodeConnectionType, Workflow, ITaskData } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import type { IAiData, IAiDataContent, INodeUi } from '@/Interface'; import type { IAiData, IAiDataContent, INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
@ -28,29 +27,21 @@ export interface Props {
runIndex?: number; runIndex?: number;
hideTitle?: boolean; hideTitle?: boolean;
slim?: boolean; slim?: boolean;
workflow: Workflow;
} }
const props = withDefaults(defineProps<Props>(), { runIndex: 0 }); const props = withDefaults(defineProps<Props>(), { runIndex: 0 });
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const selectedRun: Ref<IAiData[]> = ref([]); const selectedRun: Ref<IAiData[]> = ref([]);
function isTreeNodeSelected(node: TreeNode) { function isTreeNodeSelected(node: TreeNode) {
return selectedRun.value.some((run) => run.node === node.node && run.runIndex === node.runIndex); return selectedRun.value.some((run) => run.node === node.node && run.runIndex === node.runIndex);
} }
function getReferencedData( function getReferencedData(
reference: ITaskSubRunMetadata, taskData: ITaskData,
withInput: boolean, withInput: boolean,
withOutput: boolean, withOutput: boolean,
): IAiDataContent[] { ): IAiDataContent[] {
const resultData = workflowsStore.getWorkflowResultDataByNodeName(reference.node);
if (!resultData?.[reference.runIndex]) {
return [];
}
const taskData = resultData[reference.runIndex];
if (!taskData) { if (!taskData) {
return []; return [];
} }
@ -98,18 +89,18 @@ function onItemClick(data: TreeNode) {
return; return;
} }
const selectedNodeRun = workflowsStore.getWorkflowResultDataByNodeName(data.node)?.[
data.runIndex
];
if (!selectedNodeRun) {
return;
}
selectedRun.value = [ selectedRun.value = [
{ {
node: data.node, node: data.node,
runIndex: data.runIndex, runIndex: data.runIndex,
data: getReferencedData( data: getReferencedData(selectedNodeRun, true, true),
{
node: data.node,
runIndex: data.runIndex,
},
true,
true,
),
}, },
]; ];
} }
@ -145,21 +136,20 @@ const createNode = (
}); });
function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] { function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
const { connectionsByDestinationNode } = workflowsStore.getCurrentWorkflow(); const connections = props.workflow.connectionsByDestinationNode[nodeName];
const connections = connectionsByDestinationNode[nodeName];
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const resultData = aiData.value?.filter((data) => data.node === nodeName) ?? []; const resultData = aiData.value?.filter((data) => data.node === nodeName) ?? [];
if (!connections) { if (!connections) {
return resultData.map((d) => createNode(nodeName, currentDepth, d)); return resultData.map((d) => createNode(nodeName, currentDepth, d));
} }
const nonMainConnectionsKeys = Object.keys(connections).filter( // Get the first level of children
(key) => key !== NodeConnectionType.Main, const connectedSubNodes = props.workflow.getParentNodes(nodeName, 'ALL_NON_MAIN', 1);
);
const children = nonMainConnectionsKeys.flatMap((key) => const children = connectedSubNodes
connections[key][0].flatMap((node) => getTreeNodeData(node.node, currentDepth + 1)), // Only include sub-nodes which have data
); .filter((name) => aiData.value?.find((data) => data.node === name))
.flatMap((name) => getTreeNodeData(name, currentDepth + 1));
children.sort((a, b) => a.startTime - b.startTime); children.sort((a, b) => a.startTime - b.startTime);
@ -170,35 +160,49 @@ function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
return [createNode(nodeName, currentDepth, undefined, children)]; return [createNode(nodeName, currentDepth, undefined, children)];
} }
const aiData = computed<AIResult[] | undefined>(() => { const aiData = computed<AIResult[]>(() => {
const resultData = workflowsStore.getWorkflowResultDataByNodeName(props.node.name); const result: AIResult[] = [];
const connectedSubNodes = props.workflow.getParentNodes(props.node.name, 'ALL_NON_MAIN');
const rootNodeResult = workflowsStore.getWorkflowResultDataByNodeName(props.node.name);
const rootNodeStartTime = rootNodeResult?.[0]?.startTime ?? 0;
const rootNodeEndTime = rootNodeStartTime + (rootNodeResult?.[0]?.executionTime ?? 0);
if (!resultData || !Array.isArray(resultData)) { connectedSubNodes.forEach((nodeName) => {
return; const nodeRunData = workflowsStore.getWorkflowResultDataByNodeName(nodeName) ?? [];
}
const subRun = resultData[props.runIndex].metadata?.subRun; nodeRunData.forEach((run, runIndex) => {
if (!Array.isArray(subRun)) { const referenceData = {
return; data: getReferencedData(run, false, true)[0],
} node: nodeName,
// Extend the subRun with the data and sort by adding execution time + startTime and comparing them runIndex,
const subRunWithData = subRun.flatMap((run) => };
getReferencedData(run, false, true).map((data) => ({ ...run, data })),
);
subRunWithData.sort((a, b) => { result.push(referenceData);
const aTime = a.data?.metadata?.startTime || 0; });
const bTime = b.data?.metadata?.startTime || 0; });
// Sort the data by start time
result.sort((a, b) => {
const aTime = a.data?.metadata?.startTime ?? 0;
const bTime = b.data?.metadata?.startTime ?? 0;
return aTime - bTime; return aTime - bTime;
}); });
return subRunWithData; // Only show data that is within the root node's execution time
// This is because sub-node could be connected to multiple root nodes
const currentNodeResult = result.filter((r) => {
const startTime = r.data?.metadata?.startTime ?? 0;
return startTime >= rootNodeStartTime && startTime <= rootNodeEndTime;
});
return currentNodeResult;
}); });
const executionTree = computed<TreeNode[]>(() => { const executionTree = computed<TreeNode[]>(() => {
const rootNode = props.node; const rootNode = props.node;
const tree = getTreeNodeData(rootNode.name, 0); const tree = getTreeNodeData(rootNode.name, 1);
return tree || []; return tree || [];
}); });
@ -206,7 +210,7 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
</script> </script>
<template> <template>
<div v-if="aiData" :class="$style.container"> <div v-if="aiData.length > 0" :class="$style.container">
<div :class="{ [$style.tree]: true, [$style.slim]: slim }"> <div :class="{ [$style.tree]: true, [$style.slim]: slim }">
<ElTree <ElTree
:data="executionTree" :data="executionTree"

View file

@ -22,7 +22,6 @@ import { useUsersStore } from '@/stores/users.store';
import MessagesList from '@n8n/chat/components/MessagesList.vue'; import MessagesList from '@n8n/chat/components/MessagesList.vue';
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue'; import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
import ChatInput from '@n8n/chat/components/Input.vue'; import ChatInput from '@n8n/chat/components/Input.vue';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useRunWorkflow } from '@/composables/useRunWorkflow'; import { useRunWorkflow } from '@/composables/useRunWorkflow';
import type { Chat, ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types'; import type { Chat, ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types';
@ -78,7 +77,6 @@ interface MemoryOutput {
} }
const router = useRouter(); const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
const { runWorkflow } = useRunWorkflow({ router }); const { runWorkflow } = useRunWorkflow({ router });
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
@ -145,9 +143,9 @@ const messageVars = {
'--chat--color-typing': 'var(--color-text-dark)', '--chat--color-typing': 'var(--color-text-dark)',
}; };
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
function getTriggerNode() { function getTriggerNode() {
const workflow = workflowHelpers.getCurrentWorkflow(); const triggerNode = workflow.value.queryNodes((nodeType: INodeType) =>
const triggerNode = workflow.queryNodes((nodeType: INodeType) =>
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(nodeType.description.name), [CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(nodeType.description.name),
); );
@ -164,19 +162,19 @@ function setNode() {
return; return;
} }
const workflow = workflowHelpers.getCurrentWorkflow(); const childNodes = workflow.value.getChildNodes(triggerNode.name);
const childNodes = workflow.getChildNodes(triggerNode.name);
for (const childNode of childNodes) { for (const childNode of childNodes) {
// Look for the first connected node with metadata // Look for the first connected node with metadata
// TODO: Allow later users to change that in the UI // TODO: Allow later users to change that in the UI
const resultData = workflowsStore.getWorkflowResultDataByNodeName(childNode); const connectedSubNodes = workflow.value.getParentNodes(childNode, 'ALL_NON_MAIN');
const resultData = connectedSubNodes.map(workflowsStore.getWorkflowResultDataByNodeName);
if (!resultData && !Array.isArray(resultData)) { if (!resultData && !Array.isArray(resultData)) {
continue; continue;
} }
if (resultData[resultData.length - 1].metadata) { if (resultData.some((data) => data?.[0].metadata)) {
node.value = workflowsStore.getNodeByName(childNode); node.value = workflowsStore.getNodeByName(childNode);
break; break;
} }
@ -190,7 +188,6 @@ function setConnectedNode() {
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found'); showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
return; return;
} }
const workflow = workflowHelpers.getCurrentWorkflow();
const chatNode = workflowsStore.getNodes().find((storeNode: INodeUi): boolean => { const chatNode = workflowsStore.getNodes().find((storeNode: INodeUi): boolean => {
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false; if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
@ -202,10 +199,10 @@ function setConnectedNode() {
let isCustomChainOrAgent = false; let isCustomChainOrAgent = false;
if (nodeType.name === AI_CODE_NODE_TYPE) { if (nodeType.name === AI_CODE_NODE_TYPE) {
const inputs = NodeHelpers.getNodeInputs(workflow, storeNode, nodeType); const inputs = NodeHelpers.getNodeInputs(workflow.value, storeNode, nodeType);
const inputTypes = NodeHelpers.getConnectionTypes(inputs); const inputTypes = NodeHelpers.getConnectionTypes(inputs);
const outputs = NodeHelpers.getNodeOutputs(workflow, storeNode, nodeType); const outputs = NodeHelpers.getNodeOutputs(workflow.value, storeNode, nodeType);
const outputTypes = NodeHelpers.getConnectionTypes(outputs); const outputTypes = NodeHelpers.getConnectionTypes(outputs);
if ( if (
@ -219,7 +216,7 @@ function setConnectedNode() {
if (!isAgent && !isChain && !isCustomChainOrAgent) return false; if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
const parentNodes = workflow.getParentNodes(storeNode.name); const parentNodes = workflow.value.getParentNodes(storeNode.name);
const isChatChild = parentNodes.some((parentNodeName) => parentNodeName === triggerNode.name); const isChatChild = parentNodes.some((parentNodeName) => parentNodeName === triggerNode.name);
return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent)); return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
@ -431,10 +428,9 @@ async function sendMessage(message: string, files?: File[]) {
} }
function displayExecution(executionId: string) { function displayExecution(executionId: string) {
const workflow = workflowHelpers.getCurrentWorkflow();
const route = router.resolve({ const route = router.resolve({
name: VIEWS.EXECUTION_PREVIEW, name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflow.id, executionId }, params: { name: workflow.value.id, executionId },
}); });
window.open(route.href, '_blank'); window.open(route.href, '_blank');
} }
@ -452,9 +448,10 @@ function reuseMessage(message: ChatMessageText) {
function getChatMessages(): ChatMessageText[] { function getChatMessages(): ChatMessageText[] {
if (!connectedNode.value) return []; if (!connectedNode.value) return [];
const workflow = workflowHelpers.getCurrentWorkflow();
const connectedMemoryInputs = const connectedMemoryInputs =
workflow.connectionsByDestinationNode[connectedNode.value.name][NodeConnectionType.AiMemory]; workflow.value.connectionsByDestinationNode[connectedNode.value.name][
NodeConnectionType.AiMemory
];
if (!connectedMemoryInputs) return []; if (!connectedMemoryInputs) return [];
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0]; const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
@ -573,7 +570,13 @@ onMounted(() => {
locale.baseText('chat.window.logs') locale.baseText('chat.window.logs')
}}</n8n-text> }}</n8n-text>
<div :class="$style.logs"> <div :class="$style.logs">
<LazyRunDataAi :key="messages.length" :node="node" hide-title slim /> <LazyRunDataAi
:key="messages.length"
:node="node"
hide-title
slim
:workflow="workflow"
/>
</div> </div>
</div> </div>
</div> </div>

View file

@ -27,11 +27,11 @@ export default defineComponent({
<style lang="scss" module> <style lang="scss" module>
.wrapper { .wrapper {
display: grid; display: flex;
flex-direction: column;
height: 100%; height: 100%;
width: 100%; width: 100%;
max-width: 1280px; max-width: 1280px;
grid-template-rows: auto 1fr;
box-sizing: border-box; box-sizing: border-box;
align-content: start; align-content: start;
padding: var(--spacing-l) var(--spacing-2xl) 0; padding: var(--spacing-l) var(--spacing-2xl) 0;

View file

@ -0,0 +1,20 @@
import { createComponentRenderer } from '@/__tests__/render';
import ResourceListHeader from './ResourceListHeader.vue';
const renderComponent = createComponentRenderer(ResourceListHeader);
describe('WorkflowHeader', () => {
it('should render icon prop', () => {
const icon = 'home';
const { container } = renderComponent({ props: { icon } });
expect(container.querySelector(`.fa-${icon}`)).toBeVisible();
});
test.each([
['title', 'title slot'],
['subtitle', 'subtitle slot'],
['actions', 'actions slot'],
])('should render "%s" slot', (slot, content) => {
const { getByText } = renderComponent({ props: { icon: 'home' }, slots: { [slot]: content } });
expect(getByText(content)).toBeVisible();
});
});

View file

@ -0,0 +1,43 @@
<script setup lang="ts">
import { N8nHeading, N8nText, N8nIcon } from 'n8n-design-system';
defineProps<{ icon: string }>();
</script>
<template>
<div :class="[$style.workflowHeader]">
<div :class="[$style.icon]">
<N8nIcon :icon color="text-light"></N8nIcon>
</div>
<div>
<N8nHeading bold tag="h2" size="xlarge">
<slot name="title" />
</N8nHeading>
<N8nText v-if="$slots.subtitle" size="small" color="text-light">
<slot name="subtitle" />
</N8nText>
</div>
<div v-if="$slots.actions" :class="[$style.actions]">
<slot name="actions"></slot>
</div>
</div>
</template>
<style lang="scss" module>
.workflowHeader {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: var(--spacing-m);
min-height: 64px;
}
.icon {
border: 1px solid var(--color-foreground-light);
padding: 6px;
border-radius: var(--border-radius-base);
}
.actions {
margin-left: auto;
}
</style>

View file

@ -1,7 +1,12 @@
import { setActivePinia, createPinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { within, waitFor } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue'; import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import type router from 'vue-router'; import type router from 'vue-router';
import { mockedStore } from '@/__tests__/utils';
import { useProjectsStore } from '@/stores/projects.store';
import { type Project, ProjectTypes } from '@/types/projects.types';
vi.mock('vue-router', async (importOriginal) => { vi.mock('vue-router', async (importOriginal) => {
const { RouterLink } = await importOriginal<typeof router>(); const { RouterLink } = await importOriginal<typeof router>();
@ -18,7 +23,8 @@ const renderComponent = createComponentRenderer(ResourcesListLayout);
describe('ResourcesListLayout', () => { describe('ResourcesListLayout', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()); const pinia = createTestingPinia();
setActivePinia(pinia);
}); });
it('should render loading skeleton', () => { it('should render loading skeleton', () => {
@ -30,4 +36,46 @@ describe('ResourcesListLayout', () => {
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25); expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
}); });
describe('header', () => {
it('should render the correct icon', async () => {
const projects = mockedStore(useProjectsStore);
const { getByTestId } = renderComponent();
expect(getByTestId('list-layout-header').querySelector('.fa-home')).toBeVisible();
projects.currentProject = { type: ProjectTypes.Personal } as Project;
await waitFor(() =>
expect(getByTestId('list-layout-header').querySelector('.fa-user')).toBeVisible(),
);
const projectName = 'My Project';
projects.currentProject = { name: projectName } as Project;
await waitFor(() =>
expect(getByTestId('list-layout-header').querySelector('.fa-layer-group')).toBeVisible(),
);
});
it('should render the correct title', async () => {
const projects = mockedStore(useProjectsStore);
const { getByTestId } = renderComponent();
expect(within(getByTestId('list-layout-header')).getByText('Home')).toBeVisible();
projects.currentProject = { type: ProjectTypes.Personal } as Project;
await waitFor(() =>
expect(within(getByTestId('list-layout-header')).getByText('Personal')).toBeVisible(),
);
const projectName = 'My Project';
projects.currentProject = { name: projectName } as Project;
await waitFor(() =>
expect(within(getByTestId('list-layout-header')).getByText(projectName)).toBeVisible(),
);
});
});
}); });

View file

@ -2,16 +2,18 @@
import { computed, defineComponent, nextTick, ref, onMounted, watch } from 'vue'; import { computed, defineComponent, nextTick, ref, onMounted, watch } from 'vue';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import type { ProjectSharingData } from '@/types/projects.types'; import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue'; import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue'; import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import type { DatatableColumn } from 'n8n-design-system'; import type { DatatableColumn } from 'n8n-design-system';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce'; import { useDebounce } from '@/composables/useDebounce';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useProjectsStore } from '@/stores/projects.store';
// eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars // eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars
import type { BaseTextKey } from '@/plugins/i18n'; import type { BaseTextKey } from '@/plugins/i18n';
@ -44,6 +46,7 @@ export default defineComponent({
PageViewLayout, PageViewLayout,
PageViewLayoutList, PageViewLayoutList,
ResourceFiltersDropdown, ResourceFiltersDropdown,
ResourceListHeader,
}, },
props: { props: {
resourceKey: { resourceKey: {
@ -113,6 +116,7 @@ export default defineComponent({
const i18n = useI18n(); const i18n = useI18n();
const { callDebounced } = useDebounce(); const { callDebounced } = useDebounce();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const sortBy = ref(props.sortOptions[0]); const sortBy = ref(props.sortOptions[0]);
@ -339,10 +343,31 @@ export default defineComponent({
} }
}); });
const headerIcon = computed(() => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return 'user';
} else if (projectsStore.currentProject?.name) {
return 'layer-group';
} else {
return 'home';
}
});
const projectName = computed(() => {
if (!projectsStore.currentProject) {
return i18n.baseText('projects.menu.home');
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
} else {
return projectsStore.currentProject.name;
}
});
return { return {
i18n, i18n,
search, search,
usersStore, usersStore,
projectsStore,
filterKeys, filterKeys,
currentPage, currentPage,
rowsPerPage, rowsPerPage,
@ -362,6 +387,8 @@ export default defineComponent({
setCurrentPage, setCurrentPage,
setRowsPerPage, setRowsPerPage,
onSearch, onSearch,
headerIcon,
projectName,
}; };
}, },
}); });
@ -369,7 +396,14 @@ export default defineComponent({
<template> <template>
<PageViewLayout> <PageViewLayout>
<template #header> <slot name="header" /> </template> <template #header>
<ResourceListHeader :icon="headerIcon" data-test-id="list-layout-header">
<template #title>
{{ projectName }}
</template>
</ResourceListHeader>
<slot name="header" />
</template>
<div v-if="loading" class="resource-list-loading"> <div v-if="loading" class="resource-list-loading">
<n8n-loading :rows="25" :shrink-last="false" /> <n8n-loading :rows="25" :shrink-last="false" />
</div> </div>

View file

@ -84,7 +84,6 @@ import type {
INodeTypeDescription, INodeTypeDescription,
INodeTypeNameVersion, INodeTypeNameVersion,
IPinData, IPinData,
ITelemetryTrackProperties,
IWorkflowBase, IWorkflowBase,
NodeInputConnections, NodeInputConnections,
NodeParameterValueType, NodeParameterValueType,
@ -733,25 +732,22 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
} }
function trackAddStickyNoteNode() { function trackAddStickyNoteNode() {
telemetry.trackNodesPanel('nodeView.addSticky', { telemetry.track('User inserted workflow note', {
workflow_id: workflowsStore.workflowId, workflow_id: workflowsStore.workflowId,
}); });
} }
function trackAddDefaultNode(nodeData: INodeUi, options: AddNodeOptions) { function trackAddDefaultNode(nodeData: INodeUi, options: AddNodeOptions) {
const trackProperties: ITelemetryTrackProperties = { nodeCreatorStore.onNodeAddedToCanvas({
node_type: nodeData.type, node_type: nodeData.type,
node_version: nodeData.typeVersion, node_version: nodeData.typeVersion,
is_auto_add: options.isAutoAdd, is_auto_add: options.isAutoAdd,
workflow_id: workflowsStore.workflowId, workflow_id: workflowsStore.workflowId,
drag_and_drop: options.dragAndDrop, drag_and_drop: options.dragAndDrop,
}; input_node_type: uiStore.lastInteractedWithNode
? uiStore.lastInteractedWithNode.type
if (uiStore.lastInteractedWithNode) { : undefined,
trackProperties.input_node_type = uiStore.lastInteractedWithNode.type; });
}
telemetry.trackNodesPanel('nodeView.addNodeButton', trackProperties);
} }
/** /**

View file

@ -1,30 +0,0 @@
export const nodesPanelSession = {
pushRef: '',
data: {
nodeFilter: '',
resultsNodes: [] as string[],
filterMode: 'Regular',
},
};
export const hooksGenerateNodesPanelEvent = () => {
return {
eventName: 'User entered nodes panel search term',
properties: {
search_string: nodesPanelSession.data.nodeFilter,
results_count: nodesPanelSession.data.resultsNodes.length,
results_nodes: nodesPanelSession.data.resultsNodes,
filter_mode: nodesPanelSession.data.filterMode,
nodes_panel_session_id: nodesPanelSession.pushRef,
},
};
};
export const hooksResetNodesPanelSession = () => {
nodesPanelSession.pushRef = `nodes_panel_session_${new Date().valueOf()}`;
nodesPanelSession.data = {
nodeFilter: '',
resultsNodes: [],
filterMode: 'Regular',
};
};

View file

@ -1,2 +1 @@
export * from './hooksAddFakeDoorFeatures'; export * from './hooksAddFakeDoorFeatures';
export * from './hooksNodesPanel';

View file

@ -3,8 +3,8 @@ import type { ITelemetrySettings } from '@n8n/api-types';
import type { ITelemetryTrackProperties, IDataObject } from 'n8n-workflow'; import type { ITelemetryTrackProperties, IDataObject } from 'n8n-workflow';
import type { RouteLocation } from 'vue-router'; import type { RouteLocation } from 'vue-router';
import type { INodeCreateElement, IUpdateInformation } from '@/Interface'; import type { IUpdateInformation } from '@/Interface';
import type { IUserNodesPanelSession, RudderStack } from './telemetry.types'; import type { RudderStack } from './telemetry.types';
import { import {
APPEND_ATTRIBUTION_DEFAULT_PATH, APPEND_ATTRIBUTION_DEFAULT_PATH,
MICROSOFT_TEAMS_NODE_TYPE, MICROSOFT_TEAMS_NODE_TYPE,
@ -26,15 +26,6 @@ export class Telemetry {
return window.rudderanalytics; return window.rudderanalytics;
} }
private userNodesPanelSession: IUserNodesPanelSession = {
pushRef: '',
data: {
nodeFilter: '',
resultsNodes: [],
filterMode: 'Regular',
},
};
constructor() { constructor() {
this.pageEventQueue = []; this.pageEventQueue = [];
this.previousPath = ''; this.previousPath = '';
@ -200,78 +191,6 @@ export class Telemetry {
} }
} }
trackNodesPanel(event: string, properties: IDataObject = {}) {
if (this.rudderStack) {
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
switch (event) {
case 'nodeView.createNodeActiveChanged':
if (properties.createNodeActive !== false) {
this.resetNodesPanelSession();
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
this.track('User opened nodes panel', properties);
}
break;
case 'nodeCreateList.destroyed':
if (
this.userNodesPanelSession.data.nodeFilter.length > 0 &&
this.userNodesPanelSession.data.nodeFilter !== ''
) {
this.track('User entered nodes panel search term', this.generateNodesPanelEvent());
}
break;
case 'nodeCreateList.nodeFilterChanged':
if (
(properties.newValue as string).length === 0 &&
this.userNodesPanelSession.data.nodeFilter.length > 0
) {
this.track('User entered nodes panel search term', this.generateNodesPanelEvent());
}
if (
(properties.newValue as string).length > ((properties.oldValue as string) || '').length
) {
this.userNodesPanelSession.data.nodeFilter = properties.newValue as string;
this.userNodesPanelSession.data.resultsNodes = (
(properties.filteredNodes || []) as INodeCreateElement[]
).map((node: INodeCreateElement) => node.key);
}
break;
case 'nodeCreateList.onCategoryExpanded':
properties.is_subcategory = false;
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
this.track('User viewed node category', properties);
break;
case 'nodeCreateList.onViewActions':
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
this.track('User viewed node actions', properties);
break;
case 'nodeCreateList.onActionsCustmAPIClicked':
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
this.track('User clicked custom API from node actions', properties);
break;
case 'nodeCreateList.addAction':
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
this.track('User added action', properties);
break;
case 'nodeCreateList.onSubcategorySelected':
properties.category_name = properties.subcategory;
properties.is_subcategory = true;
properties.nodes_panel_session_id = this.userNodesPanelSession.pushRef;
delete properties.selected;
this.track('User viewed node category', properties);
break;
case 'nodeView.addNodeButton':
this.track('User added node to workflow canvas', properties, { withPostHog: true });
break;
case 'nodeView.addSticky':
this.track('User inserted workflow note', properties);
break;
default:
break;
}
}
}
// We currently do not support tracking directly from within node implementation // We currently do not support tracking directly from within node implementation
// so we are using this method as centralized way to track node parameters changes // so we are using this method as centralized way to track node parameters changes
trackNodeParametersValuesChange(nodeType: string, change: IUpdateInformation) { trackNodeParametersValuesChange(nodeType: string, change: IUpdateInformation) {
@ -295,24 +214,6 @@ export class Telemetry {
} }
} }
private resetNodesPanelSession() {
this.userNodesPanelSession.pushRef = `nodes_panel_session_${new Date().valueOf()}`;
this.userNodesPanelSession.data = {
nodeFilter: '',
resultsNodes: [],
filterMode: 'All',
};
}
private generateNodesPanelEvent() {
return {
search_string: this.userNodesPanelSession.data.nodeFilter,
results_count: this.userNodesPanelSession.data.resultsNodes.length,
filter_mode: this.userNodesPanelSession.data.filterMode,
nodes_panel_session_id: this.userNodesPanelSession.pushRef,
};
}
private initRudderStack(key: string, url: string, options: IDataObject) { private initRudderStack(key: string, url: string, options: IDataObject) {
window.rudderanalytics = window.rudderanalytics || []; window.rudderanalytics = window.rudderanalytics || [];
if (!this.rudderStack) { if (!this.rudderStack) {

View file

@ -0,0 +1,297 @@
import { createPinia, setActivePinia } from 'pinia';
import { useNodeCreatorStore } from './nodeCreator.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { CUSTOM_API_CALL_KEY, REGULAR_NODE_CREATOR_VIEW } from '@/constants';
import type { INodeCreateElement } from '@/Interface';
const workflow_id = 'workflow-id';
const category_name = 'category-name';
const source = 'source';
const mode = 'mode';
const now = 1717602004819;
const now1 = 1718602004819;
const node_type = 'node-type';
const node_version = 1;
const input_node_type = 'input-node-type';
const action = 'action';
const source_mode = 'source-mode';
const resource = 'resource';
const actions = ['action1'];
vi.mock('@/composables/useTelemetry', () => {
const track = vi.fn();
return {
useTelemetry: () => {
return {
track,
};
},
};
});
describe('useNodeCreatorStore', () => {
let nodeCreatorStore: ReturnType<typeof useNodeCreatorStore>;
beforeEach(() => {
vi.useFakeTimers();
vi.restoreAllMocks();
setActivePinia(createPinia());
nodeCreatorStore = useNodeCreatorStore();
vi.setSystemTime(now);
});
it('tracks when node creator is opened', () => {
nodeCreatorStore.onCreatorOpened({
source,
mode,
workflow_id,
});
expect(useTelemetry().track).toHaveBeenCalledWith(
'User opened nodes panel',
{
mode,
source,
nodes_panel_session_id: getSessionId(now),
workflow_id,
},
{
withPostHog: false,
},
);
});
it('resets session id every time node creator is opened', () => {
nodeCreatorStore.onCreatorOpened({
source,
mode,
workflow_id,
});
expect(useTelemetry().track).toHaveBeenCalledWith(
'User opened nodes panel',
{
mode,
source,
nodes_panel_session_id: getSessionId(now),
workflow_id,
},
{
withPostHog: false,
},
);
vi.setSystemTime(now1);
nodeCreatorStore.onCreatorOpened({
source,
mode,
workflow_id,
});
expect(useTelemetry().track).toHaveBeenCalledWith(
'User opened nodes panel',
{
mode,
source,
nodes_panel_session_id: getSessionId(now1),
workflow_id,
},
{
withPostHog: false,
},
);
});
it('tracks event on category expanded', () => {
nodeCreatorStore.onCreatorOpened({
source,
mode,
workflow_id,
});
nodeCreatorStore.onCategoryExpanded({ workflow_id, category_name });
expect(useTelemetry().track).toHaveBeenCalledWith(
'User viewed node category',
{
category_name,
is_subcategory: false,
nodes_panel_session_id: getSessionId(now),
workflow_id,
},
{
withPostHog: false,
},
);
});
it('tracks event when node is added to canvas', () => {
nodeCreatorStore.onCreatorOpened({
source,
mode,
workflow_id,
});
nodeCreatorStore.onNodeAddedToCanvas({
node_type,
node_version,
is_auto_add: true,
workflow_id,
drag_and_drop: true,
input_node_type,
});
expect(useTelemetry().track).toHaveBeenCalledWith(
'User added node to workflow canvas',
{
node_type,
node_version,
is_auto_add: true,
drag_and_drop: true,
input_node_type,
nodes_panel_session_id: getSessionId(now),
workflow_id,
},
{
withPostHog: true,
},
);
});
it('tracks event when action is added', () => {
nodeCreatorStore.onCreatorOpened({
source,
mode,
workflow_id,
});
nodeCreatorStore.onAddActions({
node_type,
action,
source_mode,
resource,
});
expect(useTelemetry().track).toHaveBeenCalledWith(
'User added action',
{
node_type,
action,
source_mode,
resource,
nodes_panel_session_id: getSessionId(now),
},
{
withPostHog: false,
},
);
});
it('tracks when custom api action is clicked', () => {
nodeCreatorStore.onCreatorOpened({
source,
mode,
workflow_id,
});
nodeCreatorStore.onActionsCustomAPIClicked({
app_identifier: node_type,
});
expect(useTelemetry().track).toHaveBeenCalledWith(
'User clicked custom API from node actions',
{
app_identifier: node_type,
nodes_panel_session_id: getSessionId(now),
},
{
withPostHog: false,
},
);
});
it('tracks when action is viewed', () => {
nodeCreatorStore.onCreatorOpened({
source,
mode,
workflow_id,
});
nodeCreatorStore.onViewActions({
app_identifier: node_type,
actions,
regular_action_count: 1,
trigger_action_count: 2,
});
expect(useTelemetry().track).toHaveBeenCalledWith(
'User viewed node actions',
{
app_identifier: node_type,
actions,
regular_action_count: 1,
trigger_action_count: 2,
nodes_panel_session_id: getSessionId(now),
},
{
withPostHog: false,
},
);
});
it('tracks when search filter is updated, ignoring custom actions in count', () => {
const newValue = 'new-value';
const subcategory = 'subcategory';
const title = 'title';
const mockTrigger = {
key: 'n8n-node.exampleTrigger',
properties: {
name: 'n8n-node.exampleTrigge',
displayName: 'Example Trigger',
},
} as INodeCreateElement;
const mockCustom = {
key: 'action',
properties: {
actionKey: CUSTOM_API_CALL_KEY,
},
} as INodeCreateElement;
const mockRegular = {
key: 'n8n-node.example',
properties: {},
} as INodeCreateElement;
nodeCreatorStore.onCreatorOpened({
source,
mode,
workflow_id,
});
nodeCreatorStore.onNodeFilterChanged({
newValue,
filteredNodes: [mockCustom, mockRegular, mockTrigger],
filterMode: REGULAR_NODE_CREATOR_VIEW,
subcategory,
title,
});
expect(useTelemetry().track).toHaveBeenCalledWith(
'User entered nodes panel search term',
{
search_string: newValue,
filter_mode: 'regular',
category_name: subcategory,
results_count: 2,
trigger_count: 1,
regular_count: 1,
nodes_panel_session_id: getSessionId(now),
title,
},
{
withPostHog: false,
},
);
});
});
function getSessionId(time: number) {
return `nodes_panel_session_${time}`;
}

View file

@ -1,6 +1,8 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { import {
AI_NODE_CREATOR_VIEW, AI_NODE_CREATOR_VIEW,
AI_OTHERS_NODE_CREATOR_VIEW,
CUSTOM_API_CALL_KEY,
NODE_CREATOR_OPEN_SOURCES, NODE_CREATOR_OPEN_SOURCES,
REGULAR_NODE_CREATOR_VIEW, REGULAR_NODE_CREATOR_VIEW,
STORES, STORES,
@ -12,17 +14,17 @@ import type {
SimplifiedNodeType, SimplifiedNodeType,
ActionsRecord, ActionsRecord,
ToggleNodeCreatorOptions, ToggleNodeCreatorOptions,
INodeCreateElement,
} from '@/Interface'; } from '@/Interface';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { transformNodeType } from '@/components/Node/NodeCreator/utils'; import { transformNodeType } from '@/components/Node/NodeCreator/utils';
import type { INodeInputConfiguration } from 'n8n-workflow'; import type { IDataObject, INodeInputConfiguration, NodeParameterValueType } from 'n8n-workflow';
import { NodeConnectionType, nodeConnectionTypes, NodeHelpers } from 'n8n-workflow'; import { NodeConnectionType, nodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useExternalHooks } from '@/composables/useExternalHooks'; import { useExternalHooks } from '@/composables/useExternalHooks';
import { useTelemetry } from '@/composables/useTelemetry';
import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks'; import { useViewStacks } from '@/components/Node/NodeCreator/composables/useViewStacks';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { import {
@ -33,15 +35,15 @@ import type { Connection } from '@vue-flow/core';
import { CanvasConnectionMode } from '@/types'; import { CanvasConnectionMode } from '@/types';
import { isVueFlowConnection } from '@/utils/typeGuards'; import { isVueFlowConnection } from '@/utils/typeGuards';
import type { PartialBy } from '@/utils/typeHelpers'; import type { PartialBy } from '@/utils/typeHelpers';
import { useTelemetry } from '@/composables/useTelemetry';
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => { export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const externalHooks = useExternalHooks();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const externalHooks = useExternalHooks();
const selectedView = ref<NodeFilterType>(TRIGGER_NODE_CREATOR_VIEW); const selectedView = ref<NodeFilterType>(TRIGGER_NODE_CREATOR_VIEW);
const mergedNodes = ref<SimplifiedNodeType[]>([]); const mergedNodes = ref<SimplifiedNodeType[]>([]);
@ -50,6 +52,10 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
const showScrim = ref(false); const showScrim = ref(false);
const openSource = ref<NodeCreatorOpenSource>(''); const openSource = ref<NodeCreatorOpenSource>('');
const isCreateNodeActive = ref<boolean>(false);
const nodePanelSessionId = ref<string>('');
const allNodeCreatorNodes = computed(() => const allNodeCreatorNodes = computed(() =>
Object.values(mergedNodes.value).map((i) => transformNodeType(i)), Object.values(mergedNodes.value).map((i) => transformNodeType(i)),
); );
@ -115,7 +121,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
createNodeActive, createNodeActive,
nodeCreatorView, nodeCreatorView,
}: ToggleNodeCreatorOptions) { }: ToggleNodeCreatorOptions) {
if (createNodeActive === uiStore.isCreateNodeActive) { if (createNodeActive === isCreateNodeActive.value) {
return; return;
} }
@ -128,54 +134,24 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
// Default to the trigger tab in node creator if there's no trigger node yet // Default to the trigger tab in node creator if there's no trigger node yet
setSelectedView(nodeCreatorView); setSelectedView(nodeCreatorView);
let mode; isCreateNodeActive.value = createNodeActive;
switch (selectedView.value) {
case AI_NODE_CREATOR_VIEW:
mode = 'ai';
break;
case REGULAR_NODE_CREATOR_VIEW:
mode = 'regular';
break;
default:
mode = 'regular';
}
uiStore.isCreateNodeActive = createNodeActive;
if (createNodeActive && source) { if (createNodeActive && source) {
setOpenSource(source); setOpenSource(source);
} }
void externalHooks.run('nodeView.createNodeActiveChanged', { void externalHooks.run('nodeView.createNodeActiveChanged', {
source, source,
mode, mode: getMode(nodeCreatorView),
createNodeActive, createNodeActive,
}); });
trackNodesPanelActiveChanged({ if (createNodeActive) {
source, onCreatorOpened({
mode, source,
createNodeActive, mode: getMode(nodeCreatorView),
workflowId: workflowsStore.workflowId, workflow_id: workflowsStore.workflowId,
}); });
} }
function trackNodesPanelActiveChanged({
source,
mode,
createNodeActive,
workflowId,
}: {
source?: string;
mode?: string;
createNodeActive?: boolean;
workflowId?: string;
}) {
telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', {
source,
mode,
createNodeActive,
workflow_id: workflowId,
});
} }
function openNodeCreatorForConnectingNode({ function openNodeCreatorForConnectingNode({
@ -264,7 +240,150 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
return filter; return filter;
} }
function resetNodesPanelSession() {
nodePanelSessionId.value = `nodes_panel_session_${new Date().valueOf()}`;
}
function trackNodeCreatorEvent(event: string, properties: IDataObject = {}, withPostHog = false) {
telemetry.track(
event,
{
...properties,
nodes_panel_session_id: nodePanelSessionId.value,
},
{
withPostHog,
},
);
}
function onCreatorOpened({
source,
mode,
workflow_id,
}: {
source?: string;
mode: string;
workflow_id?: string;
}) {
resetNodesPanelSession();
trackNodeCreatorEvent('User opened nodes panel', {
source,
mode,
workflow_id,
});
}
function onNodeFilterChanged({
newValue,
filteredNodes,
filterMode,
subcategory,
title,
}: {
newValue: string;
filteredNodes: INodeCreateElement[];
filterMode: NodeFilterType;
subcategory?: string;
title?: string;
}) {
if (!newValue.length) {
return;
}
const { results_count, trigger_count, regular_count } = filteredNodes.reduce(
(accu, node) => {
if (!('properties' in node)) {
return accu;
}
const isCustomAction =
'actionKey' in node.properties && node.properties.actionKey === CUSTOM_API_CALL_KEY;
if (isCustomAction) {
return accu;
}
const isTrigger = node.key.includes('Trigger');
return {
results_count: accu.results_count + 1,
trigger_count: accu.trigger_count + (isTrigger ? 1 : 0),
regular_count: accu.regular_count + (isTrigger ? 0 : 1),
};
},
{
results_count: 0,
trigger_count: 0,
regular_count: 0,
},
);
trackNodeCreatorEvent('User entered nodes panel search term', {
search_string: newValue,
filter_mode: getMode(filterMode),
category_name: subcategory,
results_count,
trigger_count,
regular_count,
title,
});
}
function onCategoryExpanded(properties: { category_name: string; workflow_id: string }) {
trackNodeCreatorEvent('User viewed node category', { ...properties, is_subcategory: false });
}
function onViewActions(properties: {
app_identifier: string;
actions: string[];
regular_action_count: number;
trigger_action_count: number;
}) {
trackNodeCreatorEvent('User viewed node actions', properties);
}
function onActionsCustomAPIClicked(properties: { app_identifier: string }) {
trackNodeCreatorEvent('User clicked custom API from node actions', properties);
}
function onAddActions(properties: {
node_type?: string;
action: string;
source_mode: string;
resource: NodeParameterValueType;
}) {
trackNodeCreatorEvent('User added action', properties);
}
function onSubcategorySelected(properties: { subcategory: string }) {
trackNodeCreatorEvent('User viewed node category', {
category_name: properties.subcategory,
is_subcategory: true,
});
}
function onNodeAddedToCanvas(properties: {
node_type: string;
node_version: number;
is_auto_add?: boolean;
workflow_id: string;
drag_and_drop?: boolean;
input_node_type?: string;
}) {
trackNodeCreatorEvent('User added node to workflow canvas', properties, true);
}
function getMode(mode: NodeFilterType): string {
if (mode === AI_NODE_CREATOR_VIEW || mode === AI_OTHERS_NODE_CREATOR_VIEW) {
return 'ai';
}
if (mode === TRIGGER_NODE_CREATOR_VIEW) {
return 'trigger';
}
return 'regular';
}
return { return {
isCreateNodeActive,
openSource, openSource,
selectedView, selectedView,
showScrim, showScrim,
@ -280,5 +399,13 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
openNodeCreatorForConnectingNode, openNodeCreatorForConnectingNode,
openNodeCreatorForTriggerNodes, openNodeCreatorForTriggerNodes,
allNodeCreatorNodes, allNodeCreatorNodes,
onCreatorOpened,
onNodeFilterChanged,
onCategoryExpanded,
onActionsCustomAPIClicked,
onViewActions,
onAddActions,
onSubcategorySelected,
onNodeAddedToCanvas,
}; };
}); });

View file

@ -192,7 +192,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
const bannersHeight = ref<number>(0); const bannersHeight = ref<number>(0);
const bannerStack = ref<BannerName[]>([]); const bannerStack = ref<BannerName[]>([]);
const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({}); const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({});
const isCreateNodeActive = ref<boolean>(false);
const appGridWidth = ref<number>(0); const appGridWidth = ref<number>(0);
@ -659,7 +658,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
nodeViewMoveInProgress, nodeViewMoveInProgress,
nodeViewInitialized, nodeViewInitialized,
addFirstStepOnLoad, addFirstStepOnLoad,
isCreateNodeActive,
sidebarMenuCollapsed, sidebarMenuCollapsed,
fakeDoorFeatures, fakeDoorFeatures,
bannerStack, bannerStack,

View file

@ -1450,7 +1450,7 @@ function selectNodes(ids: string[]) {
function onClickPane(position: CanvasNode['position']) { function onClickPane(position: CanvasNode['position']) {
lastClickPosition.value = [position.x, position.y]; lastClickPosition.value = [position.x, position.y];
uiStore.isCreateNodeActive = false; nodeCreatorStore.isCreateNodeActive = false;
setNodeSelected(); setNodeSelected();
} }
@ -1643,7 +1643,7 @@ onBeforeUnmount(() => {
<Suspense> <Suspense>
<LazyNodeCreation <LazyNodeCreation
v-if="!isCanvasReadOnly" v-if="!isCanvasReadOnly"
:create-node-active="uiStore.isCreateNodeActive" :create-node-active="nodeCreatorStore.isCreateNodeActive"
:node-view-scale="viewportTransform.zoom" :node-view-scale="viewportTransform.zoom"
@toggle-node-creator="onToggleNodeCreator" @toggle-node-creator="onToggleNodeCreator"
@add-nodes="onAddNodesAndConnections" @add-nodes="onAddNodesAndConnections"

View file

@ -71,7 +71,6 @@ import type {
INodeInputConfiguration, INodeInputConfiguration,
INodeTypeDescription, INodeTypeDescription,
ITaskData, ITaskData,
ITelemetryTrackProperties,
IWorkflowBase, IWorkflowBase,
Workflow, Workflow,
INodeOutputConfiguration, INodeOutputConfiguration,
@ -2398,24 +2397,19 @@ export default defineComponent({
this.uiStore.stateIsDirty = true; this.uiStore.stateIsDirty = true;
if (nodeTypeName === STICKY_NODE_TYPE) { if (nodeTypeName === STICKY_NODE_TYPE) {
this.$telemetry.trackNodesPanel('nodeView.addSticky', { this.$telemetry.track('User inserted workflow note', {
workflow_id: this.workflowsStore.workflowId, workflow_id: this.workflowsStore.workflowId,
}); });
} else { } else {
void this.externalHooks.run('nodeView.addNodeButton', { nodeTypeName }); void this.externalHooks.run('nodeView.addNodeButton', { nodeTypeName });
const trackProperties: ITelemetryTrackProperties = { this.nodeCreatorStore.onNodeAddedToCanvas({
node_type: nodeTypeName, node_type: nodeTypeName,
node_version: newNodeData.typeVersion, node_version: newNodeData.typeVersion,
is_auto_add: isAutoAdd, is_auto_add: isAutoAdd,
workflow_id: this.workflowsStore.workflowId, workflow_id: this.workflowsStore.workflowId,
drag_and_drop: options.dragAndDrop, drag_and_drop: options.dragAndDrop,
}; input_node_type: lastSelectedNode ? lastSelectedNode.type : undefined,
});
if (lastSelectedNode) {
trackProperties.input_node_type = lastSelectedNode.type;
}
this.$telemetry.trackNodesPanel('nodeView.addNodeButton', trackProperties);
} }
// Automatically deselect all nodes and select the current one and also active // Automatically deselect all nodes and select the current one and also active
@ -4242,12 +4236,13 @@ export default defineComponent({
mode, mode,
createNodeActive, createNodeActive,
}); });
this.$telemetry.trackNodesPanel('nodeView.createNodeActiveChanged', { if (createNodeActive) {
source, this.nodeCreatorStore.onCreatorOpened({
mode, source,
createNodeActive, mode,
workflow_id: this.workflowsStore.workflowId, workflow_id: this.workflowsStore.workflowId,
}); });
}
}, },
async onAddNodes( async onAddNodes(
{ nodes, connections }: AddedNodesAndConnections, { nodes, connections }: AddedNodesAndConnections,

View file

@ -8,7 +8,7 @@ import type { IUser } from '@/Interface';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue'; import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import type { Project, ProjectRelation } from '@/types/projects.types'; import { type Project, type ProjectRelation, ProjectTypes } from '@/types/projects.types';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue'; import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
@ -18,6 +18,7 @@ import type { ProjectRole } from '@/types/roles.types';
import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
type FormDataDiff = { type FormDataDiff = {
name?: string; name?: string;
@ -247,6 +248,26 @@ watch(
{ immediate: true }, { immediate: true },
); );
const headerIcon = computed(() => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return 'user';
} else if (projectsStore.currentProject?.name) {
return 'layer-group';
} else {
return 'home';
}
});
const projectName = computed(() => {
if (!projectsStore.currentProject) {
return locale.baseText('projects.menu.home');
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
return locale.baseText('projects.menu.personal');
} else {
return projectsStore.currentProject.name;
}
});
onBeforeMount(async () => { onBeforeMount(async () => {
await usersStore.fetchUsers(); await usersStore.fetchUsers();
}); });
@ -260,6 +281,11 @@ onMounted(() => {
<template> <template>
<div :class="$style.projectSettings"> <div :class="$style.projectSettings">
<div :class="$style.header"> <div :class="$style.header">
<ResourceListHeader :icon="headerIcon" data-test-id="list-layout-header">
<template #title>
{{ projectName }}
</template>
</ResourceListHeader>
<ProjectTabs /> <ProjectTabs />
</div> </div>
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">

View file

@ -53,17 +53,17 @@ describe('WorkflowsView', () => {
describe('should show empty state', () => { describe('should show empty state', () => {
it('for non setup user', () => { it('for non setup user', () => {
const { getByRole } = renderComponent({ pinia: createTestingPinia({ initialState }) }); const { getByText } = renderComponent({ pinia: createTestingPinia({ initialState }) });
expect(getByRole('heading').textContent).toBe('👋 Welcome!'); expect(getByText('👋 Welcome!')).toBeVisible();
}); });
it('for currentUser user', () => { it('for currentUser user', () => {
const pinia = createTestingPinia({ initialState }); const pinia = createTestingPinia({ initialState });
const userStore = mockedStore(useUsersStore); const userStore = mockedStore(useUsersStore);
userStore.currentUser = { firstName: 'John' } as IUser; userStore.currentUser = { firstName: 'John' } as IUser;
const { getByRole } = renderComponent({ pinia }); const { getByText } = renderComponent({ pinia });
expect(getByRole('heading').textContent).toBe('👋 Welcome John!'); expect(getByText('👋 Welcome John!')).toBeVisible();
}); });
describe('when onboardingExperiment -> False', () => { describe('when onboardingExperiment -> False', () => {

View file

@ -1,4 +1,9 @@
import type { ICredentialType, INodeProperties } from 'n8n-workflow'; import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from 'n8n-workflow';
export class IntercomApi implements ICredentialType { export class IntercomApi implements ICredentialType {
name = 'intercomApi'; name = 'intercomApi';
@ -16,4 +21,22 @@ export class IntercomApi implements ICredentialType {
default: '', default: '',
}, },
]; ];
authenticate: IAuthenticateGeneric = {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.apiKey}}',
Accept: 'application/json',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: 'https://api.intercom.io',
url: '/me',
method: 'GET',
},
};
} }

View file

@ -4,7 +4,7 @@ import type {
IHookFunctions, IHookFunctions,
ILoadOptionsFunctions, ILoadOptionsFunctions,
JsonObject, JsonObject,
IRequestOptions, IHttpRequestOptions,
IHttpRequestMethods, IHttpRequestMethods,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow';
@ -18,24 +18,16 @@ export async function intercomApiRequest(
query?: IDataObject, query?: IDataObject,
uri?: string, uri?: string,
): Promise<any> { ): Promise<any> {
const credentials = await this.getCredentials('intercomApi'); const options: IHttpRequestOptions = {
const headerWithAuthentication = Object.assign(
{},
{ Authorization: `Bearer ${credentials.apiKey}`, Accept: 'application/json' },
);
const options: IRequestOptions = {
headers: headerWithAuthentication,
method, method,
qs: query, qs: query,
uri: uri || `https://api.intercom.io${endpoint}`, url: uri ?? `https://api.intercom.io${endpoint}`,
body, body,
json: true, json: true,
}; };
try { try {
return await this.helpers.request(options); return await this.helpers.httpRequestWithAuthentication.call(this, 'intercomApi', options);
} catch (error) { } catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject); throw new NodeApiError(this.getNode(), error as JsonObject);
} }