chore(editor): Add telemetry for canvas experiment (#18871)

This commit is contained in:
Suguru Inoue
2025-08-29 12:56:25 +02:00
committed by GitHub
parent e663858c9d
commit de87f67c57
37 changed files with 330 additions and 95 deletions

View File

@@ -27,6 +27,8 @@ import { useWorkflowDiffRouting } from '@/composables/useWorkflowDiffRouting';
import { useStyles } from './composables/useStyles';
import { locale } from '@n8n/design-system';
import axios from 'axios';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
import { useNDVStore } from '@/stores/ndv.store';
const route = useRoute();
const rootStore = useRootStore();
@@ -35,6 +37,7 @@ const builderStore = useBuilderStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const ndvStore = useNDVStore();
const { setAppZIndexes } = useStyles();
@@ -57,6 +60,8 @@ const appGrid = ref<Element | null>(null);
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
const builderSidebarWidth = computed(() => builderStore.chatWidth);
useTelemetryContext({ ndv_source: computed(() => ndvStore.lastSetActiveNodeSource) });
onMounted(async () => {
setAppZIndexes();
logHiringBanner();

View File

@@ -273,7 +273,7 @@ describe('NodeErrorView.vue', () => {
await userEvent.click(button);
expect(window.open).not.toHaveBeenCalled();
expect(mockNDVStore.activeNodeName).toBe('ErrorCode');
expect(mockNDVStore.setActiveNodeName).toHaveBeenCalledWith('ErrorCode', expect.any(String));
});
it('sets active node name when error has no workflow/execution IDs', async () => {
@@ -293,7 +293,7 @@ describe('NodeErrorView.vue', () => {
await userEvent.click(button);
expect(window.open).not.toHaveBeenCalled();
expect(mockNDVStore.activeNodeName).toBe('ErrorCode');
expect(mockNDVStore.setActiveNodeName).toHaveBeenCalledWith('ErrorCode', expect.any(String));
});
});
});

View File

@@ -416,7 +416,7 @@ const onOpenErrorNodeDetailClick = () => {
});
window.open(link.href, '_blank');
} else {
ndvStore.activeNodeName = props.error.node.name;
ndvStore.setActiveNodeName(props.error.node.name, 'other');
}
};

View File

@@ -2,7 +2,7 @@
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { N8nText, N8nInput, N8nResizeWrapper, N8nInfoTip } from '@n8n/design-system';
import { computed, nextTick, ref, watch, toRef } from 'vue';
import { computed, nextTick, ref, watch, toRef, useTemplateRef } from 'vue';
import { useI18n } from '@n8n/i18n';
import {
formatAsExpression,
@@ -28,7 +28,7 @@ import { htmlEditorEventBus } from '@/event-bus';
import { hasFocusOnInput, isFocusableEl } from '@/utils/typesUtils';
import type { INodeUi, ResizeData, TargetNodeParameterContext } from '@/Interface';
import { useTelemetry } from '@/composables/useTelemetry';
import { useThrottleFn } from '@vueuse/core';
import { useActiveElement, useThrottleFn } from '@vueuse/core';
import { useExecutionData } from '@/composables/useExecutionData';
import { useWorkflowsStore } from '@/stores/workflows.store';
import ExperimentalNodeDetailsDrawer from '@/components/canvas/experimental/components/ExperimentalNodeDetailsDrawer.vue';
@@ -36,6 +36,7 @@ import { useExperimentalNdvStore } from '@/components/canvas/experimental/experi
import { useNDVStore } from '@/stores/ndv.store';
import { useVueFlow } from '@vue-flow/core';
import ExperimentalFocusPanelHeader from '@/components/canvas/experimental/components/ExperimentalFocusPanelHeader.vue';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
defineOptions({ name: 'FocusPanel' });
@@ -51,6 +52,7 @@ const emit = defineEmits<{
// ESLint: false positive
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
const inputField = ref<InstanceType<typeof N8nInput> | HTMLElement>();
const wrapperRef = useTemplateRef('wrapper');
const locale = useI18n();
const nodeHelpers = useNodeHelpers();
@@ -64,13 +66,11 @@ const experimentalNdvStore = useExperimentalNdvStore();
const ndvStore = useNDVStore();
const deviceSupport = useDeviceSupport();
const vueFlow = useVueFlow(workflowsStore.workflowId);
const activeElement = useActiveElement();
const focusedNodeParameter = computed(() => focusPanelStore.focusedNodeParameters[0]);
const resolvedParameter = computed(() =>
focusedNodeParameter.value && focusPanelStore.isRichParameter(focusedNodeParameter.value)
? focusedNodeParameter.value
: undefined,
);
useTelemetryContext({ view_shown: 'focus_panel' });
const resolvedParameter = computed(() => focusPanelStore.resolvedParameter);
const inputValue = ref<string>('');
@@ -288,6 +288,12 @@ function optionSelected(command: string) {
function closeFocusPanel() {
if (experimentalNdvStore.isNdvInFocusPanelEnabled && resolvedParameter.value) {
focusPanelStore.unsetParameters();
telemetry.track('User removed focused param', {
source: 'closeIcon',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
return;
}
@@ -365,6 +371,24 @@ watch(
{ immediate: true },
);
watch(activeElement, (active) => {
if (!node.value || !active || !wrapperRef.value?.contains(active)) {
return;
}
const path = active.closest('.parameter-input')?.getAttribute('data-parameter-path');
if (!path) {
return;
}
telemetry.track('User focused focus panel', {
node_id: node.value.id,
node_type: node.value.type,
parameter_path: path,
});
});
function onResize(event: ResizeData) {
focusPanelStore.updateWidth(event.width);
}
@@ -373,13 +397,13 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
function onOpenNdv() {
if (node.value) {
ndvStore.setActiveNodeName(node.value.name);
ndvStore.setActiveNodeName(node.value.name, 'focus_panel');
}
}
</script>
<template>
<div v-if="focusPanelActive" :class="$style.wrapper" @keydown.stop>
<div v-if="focusPanelActive" ref="wrapper" :class="$style.wrapper" @keydown.stop>
<N8nResizeWrapper
:width="focusPanelWidth"
:supported-directions="['left']"

View File

@@ -85,10 +85,13 @@ function nodeTypeSelected(value: NodeTypeSelectedPayload[]) {
function toggleFocusPanel() {
focusPanelStore.toggleFocusPanel();
telemetry.track(`User ${focusPanelStore.focusPanelActive ? 'opened' : 'closed'} focus panel`, {
telemetry.track(
focusPanelStore.focusPanelActive ? 'User opened focus panel' : 'User closed focus panel',
{
source: 'canvasButton',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
},
);
}
function onAskAssistantButtonClick() {

View File

@@ -48,7 +48,7 @@ async function createPiniaStore(isActiveNode: boolean) {
workflowsStore.nodeMetadata[node.name] = { pristine: true };
if (isActiveNode) {
ndvStore.activeNodeName = node.name;
ndvStore.setActiveNodeName(node.name, 'other');
}
await useSettingsStore().getSettings();
@@ -173,7 +173,7 @@ describe('NodeDetailsView', () => {
pinia,
});
ndvStore.activeNodeName = nodeName;
ndvStore.setActiveNodeName(nodeName, 'other');
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
await waitFor(() => expect(queryByTestId('ndv-modal')).toBeInTheDocument());

View File

@@ -39,6 +39,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@n8n/i18n';
import { storeToRefs } from 'pinia';
import { useStyles } from '@/composables/useStyles';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
const emit = defineEmits<{
saveKeyboardShortcut: [event: KeyboardEvent];
@@ -75,6 +76,7 @@ const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const deviceSupport = useDeviceSupport();
const telemetry = useTelemetry();
const telemetryContext = useTelemetryContext({ view_shown: 'ndv' });
const i18n = useI18n();
const message = useMessage();
const { APP_Z_INDEXES } = useStyles();
@@ -371,7 +373,7 @@ const onInputTableMounted = (e: { avgRowHeight: number }) => {
};
const onWorkflowActivate = () => {
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
setTimeout(() => {
void workflowActivate.activateCurrentWorkflow('ndv');
}, 1000);
@@ -517,7 +519,7 @@ const close = async () => {
workflow_id: workflowsStore.workflowId,
});
triggerWaitingWarningEnabled.value = false;
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
ndvStore.resetNDVPushRef();
};
@@ -649,6 +651,7 @@ watch(
data_pinning_tooltip_presented: pinDataDiscoveryTooltipVisible.value,
input_displayed_row_height_avg: avgInputRowHeight.value,
output_displayed_row_height_avg: avgOutputRowHeight.value,
source: telemetryContext.ndv_source?.value ?? 'other',
});
}
}, 2000); // wait for RunData to mount and present pindata discovery tooltip

View File

@@ -51,7 +51,11 @@ async function createPiniaStore(
{},
);
ndvStore.activeNodeName = activeNodeName;
if (activeNodeName) {
ndvStore.setActiveNodeName(activeNodeName, 'other');
} else {
ndvStore.unsetActiveNodeName();
}
await useSettingsStore().getSettings();
await useUsersStore().loginWithCookie();
@@ -201,7 +205,7 @@ describe('NodeDetailsViewV2', () => {
pinia,
});
ndvStore.activeNodeName = 'Manual Trigger';
ndvStore.setActiveNodeName('Manual Trigger', 'other');
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());

View File

@@ -44,6 +44,7 @@ import InputPanel from './InputPanel.vue';
import OutputPanel from './OutputPanel.vue';
import PanelDragButtonV2 from './PanelDragButtonV2.vue';
import TriggerPanel from './TriggerPanel.vue';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
const emit = defineEmits<{
saveKeyboardShortcut: [event: KeyboardEvent];
@@ -77,6 +78,7 @@ const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const deviceSupport = useDeviceSupport();
const telemetry = useTelemetry();
const telemetryContext = useTelemetryContext({ view_shown: 'ndv' });
const i18n = useI18n();
const message = useMessage();
const { APP_Z_INDEXES } = useStyles();
@@ -370,7 +372,7 @@ const onInputTableMounted = (e: { avgRowHeight: number }) => {
};
const onWorkflowActivate = () => {
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
setTimeout(() => {
void workflowActivate.activateCurrentWorkflow('ndv');
}, 1000);
@@ -491,7 +493,7 @@ const close = async () => {
workflow_id: workflowsStore.workflowId,
});
triggerWaitingWarningEnabled.value = false;
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
ndvStore.resetNDVPushRef();
};
@@ -625,6 +627,7 @@ watch(
data_pinning_tooltip_presented: pinDataDiscoveryTooltipVisible.value,
input_displayed_row_height_avg: avgInputRowHeight.value,
output_displayed_row_height_avg: avgOutputRowHeight.value,
source: telemetryContext.ndv_source?.value ?? 'other',
});
}
}, 2000); // wait for RunData to mount and present pindata discovery tooltip

View File

@@ -316,7 +316,7 @@ describe('NodeExecuteButton', () => {
await userEvent.click(getByRole('button'));
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null);
expect(ndvStore.unsetActiveNodeName).toHaveBeenCalled();
expect(workflowsStore.chatPartialExecutionDestinationNode).toBe(node.name);
expect(nodeViewEventBusEmitSpy).toHaveBeenCalledWith('openChat');
});
@@ -330,7 +330,7 @@ describe('NodeExecuteButton', () => {
await userEvent.click(getByRole('button'));
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null);
expect(ndvStore.unsetActiveNodeName).toHaveBeenCalled();
expect(workflowsStore.chatPartialExecutionDestinationNode).toBe(node.name);
expect(nodeViewEventBusEmitSpy).toHaveBeenCalledWith('openChat');
});

View File

@@ -332,7 +332,7 @@ async function onClick() {
}
if (isChatNode.value || (isChatChild.value && ndvStore.isInputPanelEmpty)) {
ndvStore.setActiveNodeName(null);
ndvStore.unsetActiveNodeName();
workflowsStore.chatPartialExecutionDestinationNode = props.nodeName;
nodeViewEventBus.emit('openChat');
} else if (isListeningForEvents.value) {

View File

@@ -86,6 +86,7 @@ import { completeExpressionSyntax, shouldConvertToExpression } from '@/utils/exp
import CssEditor from './CssEditor/CssEditor.vue';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import ExperimentalEmbeddedNdvMapper from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvMapper.vue';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
type Picker = { $emit: (arg0: string, arg1: Date) => void };
@@ -150,6 +151,7 @@ const settingsStore = useSettingsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const focusPanelStore = useFocusPanelStore();
const experimentalNdvStore = useExperimentalNdvStore();
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
@@ -1034,10 +1036,19 @@ async function optionSelected(command: string) {
case 'focus':
nodeSettingsParameters.handleFocus(node.value, props.path, props.parameter);
if (experimentalNdvStore.isNdvInFocusPanelEnabled) {
telemetry.track('User added focused param', {
source: 'parameterButton',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
} else {
telemetry.track('User opened focus panel', {
source: 'parameterButton',
parameters: focusPanelStore.focusedNodeParametersInTelemetryFormat,
});
}
return;
}
@@ -1241,6 +1252,7 @@ onClickOutside(wrapper, onBlur);
},
]"
:style="parameterInputWrapperStyle"
:data-parameter-path="path"
>
<ResourceLocator
v-if="parameter.type === 'resourceLocator'"

View File

@@ -15,6 +15,7 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
import TextWithHighlights from './TextWithHighlights.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { useElementSize } from '@vueuse/core';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
const LazyRunDataJsonActions = defineAsyncComponent(
async () => await import('@/components/RunDataJsonActions.vue'),
@@ -44,6 +45,7 @@ const ndvStore = useNDVStore();
const externalHooks = useExternalHooks();
const telemetry = useTelemetry();
const telemetryContext = useTelemetryContext();
const selectedJsonPath = ref(nonExistingJsonPath);
const draggingPath = ref<null | string>(null);
@@ -103,6 +105,7 @@ const onDragEnd = (el: HTMLElement) => {
src_view: 'json',
src_element: el,
success: false,
view_shown: telemetryContext.view_shown,
...mappingTelemetry,
};

View File

@@ -115,7 +115,7 @@ async function createPiniaWithActiveNode() {
},
};
ndvStore.activeNodeName = node.name;
ndvStore.setActiveNodeName(node.name, 'other');
return {
pinia,

View File

@@ -17,6 +17,7 @@ import { N8nIconButton, N8nInfoTip, N8nTooltip, N8nTree } from '@n8n/design-syst
import { storeToRefs } from 'pinia';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import { I18nT } from 'vue-i18n';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
const MAX_COLUMNS_LIMIT = 40;
@@ -78,6 +79,7 @@ const workflowsStore = useWorkflowsStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const telemetryContext = useTelemetryContext();
const { trackOpeningRelatedExecution, resolveRelatedExecutionUrl } = useExecutionHelpers();
const {
@@ -323,6 +325,7 @@ function onDragEnd(column: string, src: string, depth = '0') {
src_view: 'table',
src_element: src,
success: false,
view_shown: telemetryContext.view_shown,
...mappingTelemetry,
};

View File

@@ -183,7 +183,7 @@ function onLabelChange(value: string) {
function setupNode(options: MessageEventBusDestinationOptions) {
workflowsStore.removeNode(node.value);
ndvStore.activeNodeName = options.id ?? 'thisshouldnothappen';
ndvStore.setActiveNodeName(options.id ?? 'thisshouldnothappen', 'other');
workflowsStore.addNode(destinationToFakeINodeUi(options));
nodeParameters.value = options as INodeParameters;
logStreamingStore.items[destination.id!].destination = options;
@@ -294,7 +294,7 @@ function onModalClose() {
logStreamingStore.removeDestination(nodeParameters.value.id.toString());
}
}
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
callEventBus('closing', destination.id);
uiStore.stateIsDirty = false;
}

View File

@@ -350,7 +350,7 @@ const onLinkClick = (e: MouseEvent) => {
pane: 'input',
type: 'open-executions-log',
});
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
void router.push({
name: VIEWS.EXECUTIONS,
});

View File

@@ -154,7 +154,7 @@ async function setupStore() {
}),
]);
workflowsStore.workflow = workflow as IWorkflowDb;
ndvStore.activeNodeName = 'Test Node Name';
ndvStore.setActiveNodeName('Test Node Name', 'other');
return pinia;
}

View File

@@ -46,6 +46,7 @@ import pick from 'lodash/pick';
import { DateTime } from 'luxon';
import NodeExecuteButton from './NodeExecuteButton.vue';
import { I18nT } from 'vue-i18n';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
type Props = {
nodes?: IConnectedNode[];
@@ -72,6 +73,7 @@ const props = withDefaults(defineProps<Props>(), {
});
const telemetry = useTelemetry();
const telemetryContext = useTelemetryContext();
const i18n = useI18n();
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
@@ -416,6 +418,7 @@ const onDragEnd = (el: HTMLElement) => {
src_has_credential: hasCredential,
src_element: el,
success: false,
view_shown: telemetryContext.view_shown,
...mappingTelemetry,
};

View File

@@ -17,6 +17,10 @@ const { nodeId, isReadOnly, subTitle, isEmbeddedInCanvas } = defineProps<{
defineSlots<{ actions?: {} }>();
const emit = defineEmits<{
dblclickHeader: [MouseEvent];
}>();
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const { renameNode } = useCanvasOperations();
@@ -37,12 +41,6 @@ function handleValueChanged(parameterData: IUpdateInformation) {
}
}
function handleDoubleClickHeader() {
if (activeNode.value) {
ndvStore.setActiveNodeName(activeNode.value.name);
}
}
function handleCaptureWheelEvent(event: WheelEvent) {
if (event.ctrlKey) {
// If the event is pinch, let it propagate and zoom canvas
@@ -86,7 +84,7 @@ function handleCaptureWheelEvent(event: WheelEvent) {
hide-sub-connections
@value-changed="handleValueChanged"
@capture-wheel-body="handleCaptureWheelEvent"
@dblclick-header="handleDoubleClickHeader"
@dblclick-header="emit('dblclickHeader', $event)"
>
<template #actions>
<slot name="actions" />

View File

@@ -15,6 +15,7 @@ import { getNodeSubTitleText } from '@/components/canvas/experimental/experiment
import ExperimentalEmbeddedNdvActions from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvActions.vue';
import { useCanvas } from '@/composables/useCanvas';
import { useExpressionResolveCtx } from '@/components/canvas/experimental/composables/useExpressionResolveCtx';
import { useTelemetryContext } from '@/composables/useTelemetryContext';
const { nodeId, isReadOnly } = defineProps<{
nodeId: string;
@@ -27,6 +28,9 @@ const experimentalNdvStore = useExperimentalNdvStore();
const isExpanded = computed(() => !experimentalNdvStore.collapsedNodes[nodeId]);
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
useTelemetryContext({ view_shown: 'zoomed_view' });
const node = computed(() => workflowsStore.getNodeById(nodeId) ?? null);
const nodeType = computed(() => {
if (node.value) {
@@ -65,7 +69,7 @@ function handleToggleExpand() {
function handleOpenNdv() {
if (node.value) {
ndvStore.setActiveNodeName(node.value.name);
ndvStore.setActiveNodeName(node.value.name, 'canvas_zoomed_view');
}
}
@@ -98,6 +102,7 @@ watchOnce(isVisible, (visible) => {
:sub-title="subTitle"
:input-node-name="expressionResolveCtx?.inputNode?.name"
is-embedded-in-canvas
@dblclick-header="handleOpenNdv"
>
<template #actions>
<ExperimentalEmbeddedNdvActions

View File

@@ -202,7 +202,7 @@ describe('useCalloutHelpers()', () => {
},
});
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null);
expect(ndvStore.unsetActiveNodeName).toHaveBeenCalled();
expect(nodeCreatorStore.setNodeCreatorState).toHaveBeenCalledWith({
source: NODE_CREATOR_OPEN_SOURCES.TEMPLATES_CALLOUT,
createNodeActive: true,

View File

@@ -135,7 +135,7 @@ export function useCalloutHelpers() {
await nodeTypesStore.loadNodeTypesIfNotLoaded();
const items: INodeCreateElement[] = getPreBuiltAgentNodeCreatorItems();
ndvStore.setActiveNodeName(null);
ndvStore.unsetActiveNodeName();
nodeCreatorStore.setNodeCreatorState({
source: NODE_CREATOR_OPEN_SOURCES.TEMPLATES_CALLOUT,
createNodeActive: true,

View File

@@ -257,7 +257,9 @@ describe('useCanvasOperations', () => {
{ openNDV: true },
);
await waitFor(() => expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith('Test Name'));
await waitFor(() =>
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith('Test Name', 'added_new_node'),
);
});
it('should not set sticky node type as active node', async () => {
@@ -1227,7 +1229,7 @@ describe('useCanvasOperations', () => {
await renameNode(oldName, newName);
expect(workflowObject.renameNode).toHaveBeenCalledWith(oldName, newName);
expect(ndvStore.activeNodeName).toBe(newName);
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(newName, expect.any(String));
});
it('should not rename node when new name is same as old name', async () => {
@@ -1291,7 +1293,7 @@ describe('useCanvasOperations', () => {
const { revertRenameNode } = useCanvasOperations();
await revertRenameNode(currentName, oldName);
expect(ndvStore.activeNodeName).toBe(oldName);
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(oldName, expect.any(String));
});
it('should not revert node renaming when old name is same as new name', async () => {
@@ -1318,9 +1320,9 @@ describe('useCanvasOperations', () => {
ndvStore.activeNodeName = '';
const { setNodeActive } = useCanvasOperations();
setNodeActive(nodeId);
setNodeActive(nodeId, 'other');
expect(ndvStore.activeNodeName).toBe(nodeName);
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(nodeName, expect.any(String));
});
it('should not change active node name when node does not exist', () => {
@@ -1331,7 +1333,7 @@ describe('useCanvasOperations', () => {
ndvStore.activeNodeName = 'Existing Node';
const { setNodeActive } = useCanvasOperations();
setNodeActive(nodeId);
setNodeActive(nodeId, 'other');
expect(ndvStore.activeNodeName).toBe('Existing Node');
});
@@ -1343,7 +1345,7 @@ describe('useCanvasOperations', () => {
workflowsStore.getNodeById.mockImplementation(() => node);
const { setNodeActive } = useCanvasOperations();
setNodeActive(node.id);
setNodeActive(node.id, 'other');
expect(workflowsStore.setNodePristine).toHaveBeenCalledWith(node.name, false);
});
@@ -1356,9 +1358,9 @@ describe('useCanvasOperations', () => {
ndvStore.activeNodeName = '';
const { setNodeActiveByName } = useCanvasOperations();
setNodeActiveByName(nodeName);
setNodeActiveByName(nodeName, 'other');
expect(ndvStore.activeNodeName).toBe(nodeName);
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(nodeName, expect.any(String));
});
});
@@ -3165,7 +3167,7 @@ describe('useCanvasOperations', () => {
await openExecution(executionId, nodeId);
expect(workflowsStore.getNodeById).toHaveBeenCalledWith(nodeId);
expect(ndvStore.activeNodeName).toBe(mockNode.name);
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(mockNode.name, expect.any(String));
});
it('should show error when nodeId is provided but node does not exist', async () => {

View File

@@ -114,6 +114,7 @@ import cloneDeep from 'lodash/cloneDeep';
import uniq from 'lodash/uniq';
import { useExperimentalNdvStore } from '@/components/canvas/experimental/experimentalNdv.store';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import type { TelemetryNdvSource, TelemetryNdvType } from '@/types/telemetry';
type AddNodeData = Partial<INodeUi> & {
type: string;
@@ -325,7 +326,7 @@ export function useCanvasOperations() {
const isRenamingActiveNode = ndvStore.activeNodeName === currentName;
if (isRenamingActiveNode) {
ndvStore.activeNodeName = newName;
ndvStore.setActiveNodeName(newName, 'other');
}
if (trackHistory && trackBulk) {
@@ -529,22 +530,22 @@ export function useCanvasOperations() {
}
}
function setNodeActive(id: string) {
function setNodeActive(id: string, source: TelemetryNdvSource) {
const node = workflowsStore.getNodeById(id);
if (!node) {
return;
}
workflowsStore.setNodePristine(node.name, false);
setNodeActiveByName(node.name);
setNodeActiveByName(node.name, source);
}
function setNodeActiveByName(name: string) {
ndvStore.activeNodeName = name;
function setNodeActiveByName(name: string, source: TelemetryNdvSource) {
ndvStore.setActiveNodeName(name, source);
}
function clearNodeActive() {
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
}
function setNodeParameters(id: string, parameters: Record<string, unknown>) {
@@ -787,25 +788,31 @@ export function useCanvasOperations() {
nodeHelpers.updateNodeCredentialIssues(nodeData);
nodeHelpers.updateNodeInputIssues(nodeData);
const isStickyNode = nodeData.type === STICKY_NODE_TYPE;
const nextView =
isStickyNode || !options.openNDV || preventOpeningNDV
? undefined
: experimentalNdvStore.isNdvInFocusPanelEnabled &&
focusPanelStore.focusPanelActive &&
focusPanelStore.resolvedParameter === undefined
? 'focus_panel'
: experimentalNdvStore.isZoomedViewEnabled
? 'zoomed_view'
: 'ndv';
if (options.telemetry) {
trackAddNode(nodeData, options);
trackAddNode(nodeData, options, nextView);
}
if (nodeData.type !== STICKY_NODE_TYPE) {
if (!isStickyNode) {
void externalHooks.run('nodeView.addNodeButton', { nodeTypeName: nodeData.type });
if (options.openNDV && !preventOpeningNDV) {
if (
experimentalNdvStore.isNdvInFocusPanelEnabled &&
focusPanelStore.focusPanelActive &&
focusPanelStore.focusedNodeParameters.length === 0
) {
if (nextView === 'focus_panel') {
// Do nothing. The added node get selected and the details are shown in the focus panel
} else if (experimentalNdvStore.isZoomedViewEnabled) {
} else if (nextView === 'zoomed_view') {
experimentalNdvStore.setNodeNameToBeFocused(nodeData.name);
} else {
ndvStore.setActiveNodeName(nodeData.name);
}
} else if (nextView === 'ndv') {
ndvStore.setActiveNodeName(nodeData.name, 'added_new_node');
}
}
});
@@ -897,13 +904,13 @@ export function useCanvasOperations() {
}
}
function trackAddNode(nodeData: INodeUi, options: AddNodeOptions) {
function trackAddNode(nodeData: INodeUi, options: AddNodeOptions, nextView?: TelemetryNdvType) {
switch (nodeData.type) {
case STICKY_NODE_TYPE:
trackAddStickyNoteNode();
break;
default:
trackAddDefaultNode(nodeData, options);
trackAddDefaultNode(nodeData, options, nextView);
}
}
@@ -913,7 +920,11 @@ export function useCanvasOperations() {
});
}
function trackAddDefaultNode(nodeData: INodeUi, options: AddNodeOptions) {
function trackAddDefaultNode(
nodeData: INodeUi,
options: AddNodeOptions,
nextView?: TelemetryNdvType,
) {
// Extract action-related parameters from node parameters if available
const nodeParameters = nodeData.parameters;
const resource =
@@ -934,6 +945,7 @@ export function useCanvasOperations() {
resource,
operation,
action: options.actionName,
next_view_shown: nextView,
});
}
@@ -2192,7 +2204,7 @@ export function useCanvasOperations() {
if (nodeId) {
const node = workflowsStore.getNodeById(nodeId);
if (node) {
ndvStore.activeNodeName = node.name;
ndvStore.setActiveNodeName(node.name, 'other');
} else {
toast.showError(
new Error(`Node with id "${nodeId}" could not be found!`),

View File

@@ -38,6 +38,7 @@ describe('useNodeSettingsParameters', () => {
};
ndvStore.activeNodeName = 'Node1';
ndvStore.setActiveNodeName = vi.fn();
ndvStore.unsetActiveNodeName = vi.fn();
ndvStore.resetNDVPushRef = vi.fn();
focusPanelStore.openWithFocusedNodeParameter = vi.fn();
focusPanelStore.focusPanelActive = false;
@@ -73,7 +74,7 @@ describe('useNodeSettingsParameters', () => {
parameter,
});
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null);
expect(ndvStore.unsetActiveNodeName).toHaveBeenCalled();
expect(ndvStore.resetNDVPushRef).toHaveBeenCalled();
});

View File

@@ -156,7 +156,7 @@ export function useNodeSettingsParameters() {
});
if (ndvStore.activeNode) {
ndvStore.setActiveNodeName(null);
ndvStore.unsetActiveNodeName();
ndvStore.resetNDVPushRef();
}
}

View File

@@ -0,0 +1,95 @@
import { mount } from '@vue/test-utils';
import { computed, defineComponent, h } from 'vue';
import { useTelemetryContext } from './useTelemetryContext';
describe(useTelemetryContext, () => {
it('should return empty context when no context is provided', () => {
const TestComponent = defineComponent({
setup() {
const context = useTelemetryContext();
return () => h('div', JSON.stringify([context.view_shown, context.ndv_source?.value]));
},
});
expect(mount(TestComponent).text()).toBe('[null,null]');
});
it('should return context with overrides when overrides are provided', () => {
const TestComponent = defineComponent({
setup() {
const context = useTelemetryContext({ view_shown: 'ndv' });
return () => h('div', JSON.stringify([context.view_shown, context.ndv_source?.value]));
},
});
expect(mount(TestComponent).text()).toBe('["ndv",null]');
});
it('should inherit context from parent and merge with overrides', () => {
const ChildComponent = defineComponent({
setup() {
const childCtx = useTelemetryContext({ view_shown: 'ndv' });
return () => h('div', JSON.stringify([childCtx.view_shown, childCtx.ndv_source?.value]));
},
});
const ParentComponent = defineComponent({
setup() {
useTelemetryContext({
view_shown: 'focus_panel',
ndv_source: computed(() => 'added_new_node'),
});
return () => h('div', [h(ChildComponent)]);
},
});
expect(mount(ParentComponent).text()).toBe('["ndv","added_new_node"]');
});
it('should handle multiple nested contexts correctly', () => {
const Level4Component = defineComponent({
setup() {
const ctx = useTelemetryContext();
return () => h('div', JSON.stringify([ctx.view_shown, ctx.ndv_source?.value]));
},
});
const Level3Component = defineComponent({
setup() {
const ctx = useTelemetryContext({ view_shown: 'ndv' });
return () =>
h('div', [
h('div', JSON.stringify([ctx.view_shown, ctx.ndv_source?.value])),
h(Level4Component),
]);
},
});
const Level2Component = defineComponent({
setup() {
const ctx = useTelemetryContext({ ndv_source: computed(() => 'other') });
return () =>
h('div', [
h('div', JSON.stringify([ctx.view_shown, ctx.ndv_source?.value])),
h(Level3Component),
]);
},
});
const Level1Component = defineComponent({
setup() {
const ctx = useTelemetryContext({ view_shown: 'focus_panel' });
return () =>
h('div', [
h('div', JSON.stringify([ctx.view_shown, ctx.ndv_source?.value])),
h(Level2Component),
]);
},
});
expect(mount(Level1Component).text()).toBe(
[
'["focus_panel",null]',
'["focus_panel","other"]',
'["ndv","other"]',
'["ndv","other"]',
].join(''),
);
});
});

View File

@@ -0,0 +1,18 @@
import { TelemetryContextSymbol } from '@/constants';
import type { TelemetryContext } from '@/types/telemetry';
import { inject, provide } from 'vue';
/**
* Composable that injects/provides data for telemetry payload.
*
* Intended for populating telemetry payload in reusable components to include
* contextual information that depends on which part of UI it is used.
*/
export function useTelemetryContext(overrides: TelemetryContext = {}): TelemetryContext {
const ctx = inject(TelemetryContextSymbol, {});
const merged = { ...ctx, ...overrides };
provide(TelemetryContextSymbol, merged);
return merged;
}

View File

@@ -13,6 +13,7 @@ import type {
import type { ComputedRef, InjectionKey, Ref } from 'vue';
import type { ExpressionLocalResolveContext } from './types/expressions';
import { DATA_STORE_MODULE_NAME } from './features/dataStore/constants';
import type { TelemetryContext } from './types/telemetry';
export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes
export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow request metadata (i.e. headers) size in bytes
@@ -982,6 +983,7 @@ export const PopOutWindowKey: InjectionKey<Ref<Window | undefined>> = Symbol('Po
export const ExpressionLocalResolveContextSymbol: InjectionKey<
ComputedRef<ExpressionLocalResolveContext | undefined>
> = Symbol('ExpressionLocalResolveContext');
export const TelemetryContextSymbol: InjectionKey<TelemetryContext> = Symbol('TelemetryContext');
export const APP_MODALS_ELEMENT_ID = 'app-modals';
export const CODEMIRROR_TOOLTIP_CONTAINER_ELEMENT_ID = 'cm-tooltip-container';

View File

@@ -115,7 +115,7 @@ function handleResizeOverviewPanelEnd() {
}
function handleOpenNdv(treeNode: LogEntry) {
ndvStore.setActiveNodeName(treeNode.node.name);
ndvStore.setActiveNodeName(treeNode.node.name, 'logs_view');
void nextTick(() => {
const source = treeNode.runData?.source[0];

View File

@@ -62,7 +62,7 @@ const isExecuting = computed(
);
function handleClickOpenNdv() {
ndvStore.setActiveNodeName(logEntry.node.name);
ndvStore.setActiveNodeName(logEntry.node.name, 'logs_view');
}
function handleChangeDisplayMode(value: IRunDataDisplayMode) {

View File

@@ -87,6 +87,12 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
}),
);
const resolvedParameter = computed(() =>
focusedNodeParameters.value[0] && isRichParameter(focusedNodeParameters.value[0])
? focusedNodeParameters.value[0]
: undefined,
);
function _setOptions({
parameters,
isActive,
@@ -191,6 +197,7 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
focusedNodeParametersInTelemetryFormat,
lastFocusTimestamp,
focusPanelWidth,
resolvedParameter,
openWithFocusedNodeParameter,
isRichParameter,
closeFocusPanel,

View File

@@ -25,6 +25,7 @@ import { defineStore } from 'pinia';
import { v4 as uuid } from 'uuid';
import { useWorkflowsStore } from './workflows.store';
import { computed, ref } from 'vue';
import type { TelemetryNdvSource } from '@/types/telemetry';
const DEFAULT_MAIN_PANEL_DIMENSIONS = {
relativeLeft: 1,
@@ -91,6 +92,7 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
const isAutocompleteOnboarded = ref(localStorageAutoCompleteIsOnboarded.value === 'true');
const highlightDraggables = ref(false);
const lastSetActiveNodeSource = ref<TelemetryNdvSource>();
const workflowsStore = useWorkflowsStore();
@@ -215,8 +217,18 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
const isNDVOpen = computed(() => activeNodeName.value !== null);
const setActiveNodeName = (nodeName: string | null): void => {
const unsetActiveNodeName = (): void => {
activeNodeName.value = null;
lastSetActiveNodeSource.value = undefined;
};
const setActiveNodeName = (nodeName: string, source: TelemetryNdvSource): void => {
if (activeNodeName.value === nodeName) {
return;
}
activeNodeName.value = nodeName;
lastSetActiveNodeSource.value = source;
};
const setInputNodeName = (nodeName: string | undefined): void => {
@@ -411,7 +423,9 @@ export const useNDVStore = defineStore(STORES.NDV, () => {
expressionOutputItemIndex,
isTableHoverOnboarded,
mainPanelDimensions,
lastSetActiveNodeSource,
setActiveNodeName,
unsetActiveNodeName,
setInputNodeName,
setInputRunIndex,
setOutputRunIndex,

View File

@@ -43,6 +43,7 @@ import { CanvasConnectionMode } from '@/types';
import { isVueFlowConnection } from '@/utils/typeGuards';
import type { PartialBy } from '@/utils/typeHelpers';
import { useTelemetry } from '@/composables/useTelemetry';
import type { TelemetryNdvType } from '@/types/telemetry';
export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
const workflowsStore = useWorkflowsStore();
@@ -107,7 +108,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
const nodeName = node ?? ndvStore.activeNodeName;
const nodeData = nodeName ? workflowsStore.getNodeByName(nodeName) : null;
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
setTimeout(() => {
if (creatorView) {
@@ -215,7 +216,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
}
function openNodeCreatorForTriggerNodes(source: NodeCreatorOpenSource) {
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
setSelectedView(TRIGGER_NODE_CREATOR_VIEW);
setShowScrim(true);
setNodeCreatorState({
@@ -238,7 +239,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
transformNodeType(a, actionNode.properties.displayName, 'action'),
);
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
setSelectedView(REGULAR_NODE_CREATOR_VIEW);
setNodeCreatorState({
source: eventSource,
@@ -417,6 +418,7 @@ export const useNodeCreatorStore = defineStore(STORES.NODE_CREATOR, () => {
resource?: string;
operation?: string;
action?: string;
next_view_shown?: TelemetryNdvType;
}) {
trackNodeCreatorEvent('User added node to workflow canvas', properties);
}

View File

@@ -0,0 +1,16 @@
import type { ComputedRef } from 'vue';
export type TelemetryNdvType = 'ndv' | 'focus_panel' | 'zoomed_view';
export type TelemetryNdvSource =
| 'added_new_node'
| 'canvas_default_view'
| 'canvas_zoomed_view'
| 'focus_panel'
| 'logs_view'
| 'other';
export type TelemetryContext = Partial<{
view_shown: TelemetryNdvType;
ndv_source: ComputedRef<TelemetryNdvSource | undefined>;
}>;

View File

@@ -769,7 +769,7 @@ function onSetNodeActivated(id: string, event?: MouseEvent) {
}
}
setNodeActive(id);
setNodeActive(id, 'canvas_default_view');
}
function onOpenSubWorkflow(id: string) {
@@ -1212,7 +1212,7 @@ function onSwitchActiveNode(nodeName: string) {
const node = workflowsStore.getNodeByName(nodeName);
if (!node) return;
setNodeActiveByName(nodeName);
setNodeActiveByName(nodeName, 'other');
selectNodes([node.id]);
}
@@ -1773,7 +1773,7 @@ function registerCustomActions() {
registerCustomAction({
key: 'openNodeDetail',
action: ({ node }: { node: string }) => {
setNodeActiveByName(node);
setNodeActiveByName(node, 'other');
},
});
@@ -1795,7 +1795,7 @@ function registerCustomActions() {
registerCustomAction({
key: 'showNodeCreator',
action: () => {
ndvStore.activeNodeName = null;
ndvStore.unsetActiveNodeName();
void nextTick(() => {
void onOpenNodeCreatorForTriggerNodes(NODE_CREATOR_OPEN_SOURCES.TAB);
@@ -1824,7 +1824,7 @@ function showAddFirstStepIfEnabled() {
function updateNodeRoute(nodeId: string) {
const nodeUi = workflowsStore.findNodeByPartialId(nodeId);
if (nodeUi) {
setNodeActive(nodeUi.id);
setNodeActive(nodeUi.id, 'other');
} else {
toast.showToast({
title: i18n.baseText('nodeView.showMessage.ndvUrl.missingNodes.title'),
@@ -1889,7 +1889,7 @@ watch(
watch(
() => route.params.nodeId,
async (newId) => {
if (typeof newId !== 'string' || newId === '') ndvStore.activeNodeName = null;
if (typeof newId !== 'string' || newId === '') ndvStore.unsetActiveNodeName();
else {
updateNodeRoute(newId);
}