test(editor): Add tests for node settings parameters composable (no-changelog) (#17232)

This commit is contained in:
Daria
2025-07-11 17:21:14 +03:00
committed by GitHub
parent 25139b2c77
commit 4eb18b7dc7
10 changed files with 272 additions and 59 deletions

View File

@@ -24,7 +24,7 @@ export default defineConfig(frontendConfig, {
'@typescript-eslint/dot-notation': 'warn',
'@stylistic/lines-between-class-members': 'warn',
'@stylistic/member-delimiter-style': 'warn',
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-empty-interface': 'warn',
'@typescript-eslint/no-for-in-array': 'warn',
'@typescript-eslint/no-loop-func': 'warn',

View File

@@ -132,7 +132,6 @@ declare global {
disallowReturnToOpener?: boolean;
}) => Promise<Window>;
};
// eslint-disable-next-line @typescript-eslint/naming-convention
Cypress: unknown;
}
}

View File

@@ -303,7 +303,6 @@ describe('useActionsGenerator', () => {
noDataExpression: true,
displayOptions: {
show: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'@version': [1],
resource: ['user'],
},
@@ -324,7 +323,6 @@ describe('useActionsGenerator', () => {
noDataExpression: true,
displayOptions: {
show: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'@version': [2],
resource: ['user'],
},
@@ -369,7 +367,6 @@ describe('useActionsGenerator', () => {
noDataExpression: true,
displayOptions: {
show: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'@version': [1, 2],
resource: ['user'],
},

View File

@@ -42,6 +42,7 @@ import {
isResourceLocatorParameterType,
isValidParameterOption,
parseFromExpression,
shouldSkipParamValidation,
} from '@/utils/nodeSettingsUtils';
import { hasExpressionMapping, isValueExpression } from '@/utils/nodeTypesUtils';
@@ -426,7 +427,7 @@ const getIssues = computed<string[]>(() => {
let checkValues: string[] = [];
if (!nodeSettingsParameters.shouldSkipParamValidation(displayValue.value)) {
if (!shouldSkipParamValidation(displayValue.value)) {
if (Array.isArray(displayValue.value)) {
checkValues = checkValues.concat(displayValue.value);
} else {

View File

@@ -10,6 +10,7 @@ import { computed } from 'vue';
import { useNDVStore } from '@/stores/ndv.store';
import { usePostHog } from '@/stores/posthog.store';
import { AI_TRANSFORM_NODE_TYPE, FOCUS_PANEL_EXPERIMENT } from '@/constants';
import { getParameterTypeOption } from '@/utils/nodeSettingsUtils';
interface Props {
parameter: INodeProperties;
@@ -41,12 +42,28 @@ const i18n = useI18n();
const ndvStore = useNDVStore();
const posthogStore = usePostHog();
const activeNode = computed(() => ndvStore.activeNode);
const isDefault = computed(() => props.parameter.default === props.value);
const isValueAnExpression = computed(() => isValueExpression(props.parameter, props.value));
const isHtmlEditor = computed(() => getArgument('editor') === 'htmlEditor');
const isHtmlEditor = computed(
() => getParameterTypeOption(props.parameter, 'editor') === 'htmlEditor',
);
const shouldShowExpressionSelector = computed(
() => !props.parameter.noDataExpression && props.showExpressionSelector && !props.isReadOnly,
);
const isFocusPanelFeatureEnabled = computed(() => {
return posthogStore.getVariant(FOCUS_PANEL_EXPERIMENT.name) === FOCUS_PANEL_EXPERIMENT.variant;
});
const hasFocusAction = computed(
() =>
isFocusPanelFeatureEnabled.value &&
!props.parameter.isNodeSetting &&
!props.isReadOnly &&
activeNode.value && // checking that it's inside ndv
(props.parameter.type === 'string' || props.parameter.type === 'json'),
);
const shouldShowOptions = computed(() => {
if (props.isReadOnly) {
return false;
@@ -71,7 +88,6 @@ const shouldShowOptions = computed(() => {
return false;
});
const selectedView = computed(() => (isValueAnExpression.value ? 'expression' : 'fixed'));
const activeNode = computed(() => ndvStore.activeNode);
const hasRemoteMethod = computed(
() =>
!!props.parameter.typeOptions?.loadOptionsMethod || !!props.parameter.typeOptions?.loadOptions,
@@ -84,18 +100,6 @@ const resetValueLabel = computed(() => {
return i18n.baseText('parameterInput.resetValue');
});
const isFocusPanelFeatureEnabled = computed(() => {
return posthogStore.getVariant(FOCUS_PANEL_EXPERIMENT.name) === FOCUS_PANEL_EXPERIMENT.variant;
});
const hasFocusAction = computed(
() =>
isFocusPanelFeatureEnabled.value &&
!props.parameter.isNodeSetting &&
!props.isReadOnly &&
activeNode.value && // checking that it's inside ndv
(props.parameter.type === 'string' || props.parameter.type === 'json'),
);
const actions = computed(() => {
if (Array.isArray(props.customActions) && props.customActions.length > 0) {
return props.customActions;
@@ -161,17 +165,6 @@ const onViewSelected = (selected: string) => {
emit('update:modelValue', 'removeExpression');
}
};
const getArgument = (argumentName: string) => {
if (props.parameter.typeOptions === undefined) {
return undefined;
}
if (props.parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return props.parameter.typeOptions[argumentName];
};
</script>
<template>

View File

@@ -45,10 +45,11 @@ import { STORES } from '@n8n/stores';
import type { Connection } from '@vue-flow/core';
import { useClipboard } from '@/composables/useClipboard';
import { createCanvasConnectionHandleString } from '@/utils/canvasUtils';
import { nextTick } from 'vue';
import { nextTick, ref } from 'vue';
import type { CanvasLayoutEvent } from './useCanvasLayout';
import { useTelemetry } from './useTelemetry';
import { useToast } from '@/composables/useToast';
import * as nodeHelpers from '@/composables/useNodeHelpers';
vi.mock('n8n-workflow', async (importOriginal) => {
const actual = await importOriginal<{}>();
@@ -2823,7 +2824,15 @@ describe('useCanvasOperations', () => {
const uiStore = mockedStore(useUIStore);
const executionsStore = mockedStore(useExecutionsStore);
const nodeHelpers = { credentialsUpdated: { value: true } };
const credentialsUpdatedRef = ref(true);
const credentialsSpy = vi.spyOn(credentialsUpdatedRef, 'value', 'set');
const nodeHelpersOriginal = nodeHelpers.useNodeHelpers();
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => {
return {
...nodeHelpersOriginal,
credentialsUpdated: credentialsUpdatedRef,
};
});
nodeCreatorStore.setNodeCreatorState = vi.fn();
nodeCreatorStore.setShowScrim = vi.fn();
@@ -2854,7 +2863,6 @@ describe('useCanvasOperations', () => {
startedAt: new Date(),
},
];
nodeHelpers.credentialsUpdated.value = true;
const { resetWorkspace } = useCanvasOperations();
@@ -2872,6 +2880,8 @@ describe('useCanvasOperations', () => {
expect(uiStore.resetLastInteractedWith).toHaveBeenCalled();
expect(uiStore.stateIsDirty).toBe(false);
expect(executionsStore.activeExecution).toBeNull();
expect(credentialsSpy).toHaveBeenCalledWith(false);
expect(credentialsUpdatedRef.value).toBe(false);
});
it('should not call removeTestWebhook if executionWaitingForWebhook is false', () => {

View File

@@ -1,9 +1,14 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNodeSettingsParameters } from './useNodeSettingsParameters';
import type { INodeProperties } from 'n8n-workflow';
import * as nodeHelpers from '@/composables/useNodeHelpers';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import * as nodeSettingsUtils from '@/utils/nodeSettingsUtils';
import * as nodeTypesUtils from '@/utils/nodeTypesUtils';
import type { INodeProperties, INodeTypeDescription } from 'n8n-workflow';
import type { MockedStore } from '@/__tests__/utils';
import { mockedStore } from '@/__tests__/utils';
import type { INodeUi } from '@/Interface';
@@ -55,7 +60,7 @@ describe('useNodeSettingsParameters', () => {
let focusPanelStore: MockedStore<typeof useFocusPanelStore>;
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createTestingPinia());
ndvStore = mockedStore(useNDVStore);
focusPanelStore = mockedStore(useFocusPanelStore);
@@ -75,6 +80,10 @@ describe('useNodeSettingsParameters', () => {
focusPanelStore.focusPanelActive = false;
});
afterEach(() => {
vi.clearAllMocks();
});
it('sets focused node parameter', () => {
const { handleFocus } = useNodeSettingsParameters();
const node: INodeUi = {
@@ -120,4 +129,204 @@ describe('useNodeSettingsParameters', () => {
expect(focusPanelStore.openWithFocusedNodeParameter).not.toHaveBeenCalled();
});
});
describe('shouldDisplayNodeParameter', () => {
const displayParameterSpy = vi.fn();
function mockNodeHelpers({ isCustomApiCallSelected = false } = {}) {
const originalNodeHelpers = nodeHelpers.useNodeHelpers();
vi.spyOn(nodeHelpers, 'useNodeHelpers').mockImplementation(() => {
return {
...originalNodeHelpers,
isCustomApiCallSelected: vi.fn(() => isCustomApiCallSelected),
displayParameter: displayParameterSpy,
};
});
}
let nodeTypesStore: MockedStore<typeof useNodeTypesStore>;
const mockParameter: INodeProperties = {
name: 'foo',
type: 'string',
displayName: 'Foo',
displayOptions: {},
default: '',
};
const mockNodeType: INodeTypeDescription = {
version: 1,
name: 'testNode',
displayName: 'Test Node',
description: 'A test node',
group: ['input'],
defaults: {
name: 'Test Node',
},
inputs: ['main'],
outputs: [],
properties: [mockParameter],
};
beforeEach(() => {
setActivePinia(createTestingPinia());
nodeTypesStore = mockedStore(useNodeTypesStore);
nodeTypesStore.getNodeType = vi.fn().mockReturnValueOnce(mockNodeType);
});
afterEach(() => {
vi.clearAllMocks();
});
it('returns false for hidden parameter type', () => {
mockNodeHelpers();
const { shouldDisplayNodeParameter } = useNodeSettingsParameters();
const result = shouldDisplayNodeParameter({}, null, { ...mockParameter, type: 'hidden' });
expect(result).toBe(false);
});
it('returns false for custom API call with mustHideDuringCustomApiCall', () => {
vi.spyOn(nodeSettingsUtils, 'mustHideDuringCustomApiCall').mockReturnValueOnce(true);
mockNodeHelpers({ isCustomApiCallSelected: true });
const { shouldDisplayNodeParameter } = useNodeSettingsParameters();
const result = shouldDisplayNodeParameter({}, null, mockParameter);
expect(result).toBe(false);
});
it('returns false if parameter is auth-related', () => {
vi.spyOn(nodeTypesUtils, 'isAuthRelatedParameter').mockReturnValueOnce(true);
vi.spyOn(nodeTypesUtils, 'getMainAuthField').mockReturnValueOnce(mockParameter);
mockNodeHelpers();
const { shouldDisplayNodeParameter } = useNodeSettingsParameters();
const result = shouldDisplayNodeParameter({}, null, mockParameter);
expect(result).toBe(false);
});
it('returns true if displayOptions is undefined', () => {
vi.spyOn(nodeTypesUtils, 'isAuthRelatedParameter').mockReturnValueOnce(false);
mockNodeHelpers();
const { shouldDisplayNodeParameter } = useNodeSettingsParameters();
const result = shouldDisplayNodeParameter({}, null, {
...mockParameter,
displayOptions: undefined,
});
expect(result).toBe(true);
});
it('calls displayParameter with correct arguments', () => {
vi.spyOn(nodeTypesUtils, 'isAuthRelatedParameter').mockReturnValueOnce(false);
mockNodeHelpers();
displayParameterSpy.mockReturnValueOnce(false);
const { shouldDisplayNodeParameter } = useNodeSettingsParameters();
const parameter: INodeProperties = {
name: 'foo',
type: 'string',
displayName: 'Foo',
disabledOptions: {},
default: '',
};
const nodeParameters = { foo: 'bar' };
const node: INodeUi = {
id: '1',
name: 'Node1',
position: [0, 0],
typeVersion: 1,
type: 'n8n-nodes-base.set',
parameters: nodeParameters,
};
const result = shouldDisplayNodeParameter(
nodeParameters,
node,
parameter,
'',
'disabledOptions',
);
expect(displayParameterSpy).toHaveBeenCalledWith(
nodeParameters,
parameter,
'',
node,
'disabledOptions',
);
expect(result).toBe(false);
});
it('calls displayParameter with default displayOptions', () => {
vi.spyOn(nodeTypesUtils, 'isAuthRelatedParameter').mockReturnValueOnce(false);
mockNodeHelpers();
displayParameterSpy.mockReturnValueOnce(true);
const { shouldDisplayNodeParameter } = useNodeSettingsParameters();
const nodeParameters = { foo: 'bar' };
const node: INodeUi = {
id: '1',
name: 'Node1',
position: [0, 0],
typeVersion: 1,
type: 'n8n-nodes-base.set',
parameters: nodeParameters,
};
const result = shouldDisplayNodeParameter(nodeParameters, node, mockParameter);
expect(displayParameterSpy).toHaveBeenCalledWith(
nodeParameters,
mockParameter,
'',
node,
'displayOptions',
);
expect(result).toBe(true);
});
it('resolves expressions and calls displayParameter with resolved parameters', () => {
vi.spyOn(nodeTypesUtils, 'isAuthRelatedParameter').mockReturnValueOnce(false);
mockNodeHelpers();
displayParameterSpy.mockReturnValueOnce(true);
const originalWorkflowHelpers = workflowHelpers.useWorkflowHelpers();
vi.spyOn(workflowHelpers, 'useWorkflowHelpers').mockImplementation(() => ({
...originalWorkflowHelpers,
resolveExpression: (expr: string) => (expr === '=1+1' ? 2 : expr),
}));
const { shouldDisplayNodeParameter } = useNodeSettingsParameters();
const nodeParameters = { foo: '=1+1' };
const node: INodeUi = {
id: '1',
name: 'Node1',
position: [0, 0],
typeVersion: 1,
type: 'n8n-nodes-base.set',
parameters: nodeParameters,
};
const result = shouldDisplayNodeParameter(nodeParameters, node, mockParameter);
expect(displayParameterSpy).toHaveBeenCalledWith(
{ foo: 2 },
mockParameter,
'',
node,
'displayOptions',
);
expect(displayParameterSpy).toHaveBeenCalled();
expect(result).toBe(true);
});
});
});

View File

@@ -24,7 +24,7 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { CUSTOM_API_CALL_KEY, KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import { KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import { omitKey } from '@/utils/objectUtils';
import {
getMainAuthField,
@@ -322,7 +322,7 @@ export function useNodeSettingsParameters() {
value,
nodeParams,
) as NodeParameterValue;
} catch (e) {
} catch {
// If expression is invalid ignore
nodeParams[key] = '';
}
@@ -352,10 +352,6 @@ export function useNodeSettingsParameters() {
return nodeHelpers.displayParameter(nodeParameters, parameter, path, node, displayKey);
}
function shouldSkipParamValidation(value: string | number | boolean | null) {
return typeof value === 'string' && value.includes(CUSTOM_API_CALL_KEY);
}
return {
nodeValues,
setValue,
@@ -363,6 +359,5 @@ export function useNodeSettingsParameters() {
updateParameterByPath,
updateNodeParameter,
handleFocus,
shouldSkipParamValidation,
};
}

View File

@@ -70,7 +70,7 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
}),
);
const _setOptions = ({
function _setOptions({
parameters,
isActive,
wid = workflowsStore.workflowId,
@@ -80,7 +80,7 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
parameters?: FocusedNodeParameter[];
wid?: string;
removeEmpty?: boolean;
}) => {
}) {
const focusPanelDataCurrent = focusPanelData.value;
if (removeEmpty && PLACEHOLDER_EMPTY_WORKFLOW_ID in focusPanelDataCurrent) {
@@ -94,10 +94,10 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
parameters: parameters ?? _focusedNodeParameters.value,
},
});
};
}
// When a new workflow is saved, we should update the focus panel data with the new workflow ID
const onNewWorkflowSave = (wid: string) => {
function onNewWorkflowSave(wid: string) {
if (!currentFocusPanelData.value || !(PLACEHOLDER_EMPTY_WORKFLOW_ID in focusPanelData.value)) {
return;
}
@@ -109,29 +109,29 @@ export const useFocusPanelStore = defineStore(STORES.FOCUS_PANEL, () => {
isActive: latestWorkflowData.isActive,
removeEmpty: true,
});
};
}
const openWithFocusedNodeParameter = (nodeParameter: FocusedNodeParameter) => {
function openWithFocusedNodeParameter(nodeParameter: FocusedNodeParameter) {
const parameters = [nodeParameter];
// TODO: uncomment when tabs are implemented
// ...focusedNodeParameters.value.filter((p) => p.parameterPath !== nodeParameter.parameterPath),
_setOptions({ parameters, isActive: true });
};
}
const closeFocusPanel = () => {
function closeFocusPanel() {
_setOptions({ isActive: false });
};
}
const toggleFocusPanel = () => {
function toggleFocusPanel() {
_setOptions({ isActive: !focusPanelActive.value });
};
}
const isRichParameter = (
function isRichParameter(
p: RichFocusedNodeParameter | FocusedNodeParameter,
): p is RichFocusedNodeParameter => {
): p is RichFocusedNodeParameter {
return 'value' in p && 'node' in p;
};
}
return {
focusPanelActive,

View File

@@ -19,7 +19,7 @@ import {
isResourceLocatorValue,
} from 'n8n-workflow';
import type { INodeUi, IUpdateInformation } from '@/Interface';
import { SWITCH_NODE_TYPE } from '@/constants';
import { CUSTOM_API_CALL_KEY, SWITCH_NODE_TYPE } from '@/constants';
import isEqual from 'lodash/isEqual';
import get from 'lodash/get';
import set from 'lodash/set';
@@ -166,7 +166,12 @@ export function removeMismatchedOptionValues(
nodeType.properties.forEach((prop) => {
const displayOptions = prop.displayOptions;
// Not processing parameters that are not set or don't have options
if (!nodeParameterValues?.hasOwnProperty(prop.name) || !displayOptions || !prop.options) {
if (
!nodeParameterValues ||
!Object.prototype.hasOwnProperty.call(nodeParameterValues, prop.name) ||
!displayOptions ||
!prop.options
) {
return;
}
// Only process the parameters that depend on the updated parameter
@@ -353,3 +358,7 @@ export function parseFromExpression(
return null;
}
export function shouldSkipParamValidation(value: string | number | boolean | null) {
return typeof value === 'string' && value.includes(CUSTOM_API_CALL_KEY);
}