refactor(editor): Move optionSelected logic to a composable (#16968)

This commit is contained in:
Daria
2025-07-07 15:20:06 +03:00
committed by GitHub
parent 32bb33bd65
commit 534f34d4c1
10 changed files with 764 additions and 433 deletions

View File

@@ -31,6 +31,7 @@ import NodeSettingsHeader from '@/components/NodeSettingsHeader.vue';
import get from 'lodash/get'; import get from 'lodash/get';
import NodeExecuteButton from './NodeExecuteButton.vue'; import NodeExecuteButton from './NodeExecuteButton.vue';
import { nameIsParameter } from '@/utils/nodeSettingsUtils';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils'; import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
@@ -369,7 +370,7 @@ const valueChanged = (parameterData: IUpdateInformation) => {
nodeHelpers.updateNodeParameterIssuesByName(_node.name); nodeHelpers.updateNodeParameterIssuesByName(_node.name);
nodeHelpers.updateNodeCredentialIssuesByName(_node.name); nodeHelpers.updateNodeCredentialIssuesByName(_node.name);
} }
} else if (nodeSettingsParameters.nameIsParameter(parameterData)) { } else if (nameIsParameter(parameterData)) {
// A node parameter changed // A node parameter changed
nodeSettingsParameters.updateNodeParameter(parameterData, newValue, _node, isToolNode.value); nodeSettingsParameters.updateNodeParameter(parameterData, newValue, _node, isToolNode.value);
} else { } else {

View File

@@ -18,7 +18,6 @@ import type {
INodeParameterResourceLocator, INodeParameterResourceLocator,
INodeParameters, INodeParameters,
INodeProperties, INodeProperties,
INodePropertyCollection,
INodePropertyOptions, INodePropertyOptions,
IParameterLabel, IParameterLabel,
NodeParameterValueType, NodeParameterValueType,
@@ -37,6 +36,13 @@ import ResourceLocator from '@/components/ResourceLocator/ResourceLocator.vue';
import SqlEditor from '@/components/SqlEditor/SqlEditor.vue'; import SqlEditor from '@/components/SqlEditor/SqlEditor.vue';
import TextEdit from '@/components/TextEdit.vue'; import TextEdit from '@/components/TextEdit.vue';
import {
formatAsExpression,
getParameterTypeOption,
isResourceLocatorParameterType,
isValidParameterOption,
parseFromExpression,
} from '@/utils/nodeSettingsUtils';
import { hasExpressionMapping, isValueExpression } from '@/utils/nodeTypesUtils'; import { hasExpressionMapping, isValueExpression } from '@/utils/nodeTypesUtils';
import { import {
@@ -54,23 +60,23 @@ import { useI18n } from '@n8n/i18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { htmlEditorEventBus } from '@/event-bus'; import { htmlEditorEventBus } from '@/event-bus';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { isCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes'; import { useUIStore } from '@/stores/ui.store';
import { N8nIcon, N8nInput, N8nInputNumber, N8nOption, N8nSelect } from '@n8n/design-system'; import { N8nIcon, N8nInput, N8nInputNumber, N8nOption, N8nSelect } from '@n8n/design-system';
import type { EventBus } from '@n8n/utils/event-bus'; import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus'; import { createEventBus } from '@n8n/utils/event-bus';
import { useElementSize } from '@vueuse/core'; import { useElementSize } from '@vueuse/core';
import { captureMessage } from '@sentry/vue'; import { captureMessage } from '@sentry/vue';
import { isCredentialOnlyNodeType } from '@/utils/credentialOnlyNodes';
import { hasFocusOnInput, isBlurrableEl, isFocusableEl, isSelectableEl } from '@/utils/typesUtils';
import { completeExpressionSyntax, shouldConvertToExpression } from '@/utils/expressions'; import { completeExpressionSyntax, shouldConvertToExpression } from '@/utils/expressions';
import { isPresent } from '@/utils/typesUtils';
import CssEditor from './CssEditor/CssEditor.vue'; import CssEditor from './CssEditor/CssEditor.vue';
import { useUIStore } from '@/stores/ui.store';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
type Picker = { $emit: (arg0: string, arg1: Date) => void }; type Picker = { $emit: (arg0: string, arg1: Date) => void };
@@ -125,6 +131,7 @@ const i18n = useI18n();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const { debounce } = useDebounce(); const { debounce } = useDebounce();
const workflowHelpers = useWorkflowHelpers(); const workflowHelpers = useWorkflowHelpers();
const nodeSettingsParameters = useNodeSettingsParameters();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const credentialsStore = useCredentialsStore(); const credentialsStore = useCredentialsStore();
@@ -133,7 +140,6 @@ const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore(); const uiStore = useUIStore();
const focusPanelStore = useFocusPanelStore();
// ESLint: false positive // ESLint: false positive
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
@@ -181,6 +187,77 @@ const dateTimePickerOptions = ref({
}); });
const isFocused = ref(false); const isFocused = ref(false);
const node = computed(() => ndvStore.activeNode ?? undefined);
const nodeType = computed(
() => node.value && nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion),
);
const shortPath = computed<string>(() => {
const short = props.path.split('.');
short.shift();
return short.join('.');
});
function getTypeOption<T>(optionName: string): T {
return getParameterTypeOption<T>(props.parameter, optionName);
}
const isModelValueExpression = computed(() => isValueExpression(props.parameter, props.modelValue));
const isResourceLocatorParameter = computed<boolean>(() => {
return isResourceLocatorParameterType(props.parameter.type);
});
const isSecretParameter = computed<boolean>(() => {
return getTypeOption('password') === true;
});
const hasRemoteMethod = computed<boolean>(() => {
return !!getTypeOption('loadOptionsMethod') || !!getTypeOption('loadOptions');
});
const parameterOptions = computed(() => {
const options = hasRemoteMethod.value ? remoteParameterOptions.value : props.parameter.options;
const safeOptions = (options ?? []).filter(isValidParameterOption);
return safeOptions;
});
const modelValueString = computed<string>(() => {
return props.modelValue as string;
});
const modelValueResourceLocator = computed<INodeParameterResourceLocator>(() => {
return props.modelValue as INodeParameterResourceLocator;
});
const modelValueExpressionEdit = computed<string>(() => {
return isResourceLocatorParameter.value && typeof props.modelValue !== 'string'
? props.modelValue
? ((props.modelValue as INodeParameterResourceLocator).value as string)
: ''
: (props.modelValue as string);
});
const editorRows = computed(() => getTypeOption<number>('rows'));
const editorType = computed<EditorType | 'json' | 'code' | 'cssEditor'>(() => {
return getTypeOption<EditorType>('editor');
});
const editorIsReadOnly = computed<boolean>(() => {
return getTypeOption<boolean>('editorIsReadOnly') ?? false;
});
const editorLanguage = computed<CodeNodeEditorLanguage>(() => {
if (editorType.value === 'json' || props.parameter.type === 'json')
return 'json' as CodeNodeEditorLanguage;
return getTypeOption<CodeNodeEditorLanguage>('editorLanguage') ?? 'javaScript';
});
const codeEditorMode = computed<CodeExecutionMode>(() => {
return node.value?.parameters.mode as CodeExecutionMode;
});
const displayValue = computed(() => { const displayValue = computed(() => {
if (remoteParameterOptionsLoadingIssues.value) { if (remoteParameterOptionsLoadingIssues.value) {
if (!nodeType.value || nodeType.value?.codex?.categories?.includes(CORE_NODES_CATEGORY)) { if (!nodeType.value || nodeType.value?.codex?.categories?.includes(CORE_NODES_CATEGORY)) {
@@ -234,7 +311,7 @@ const displayValue = computed(() => {
if ( if (
Array.isArray(returnValue) && Array.isArray(returnValue) &&
props.parameter.type === 'color' && props.parameter.type === 'color' &&
getArgument('showAlpha') === true && getTypeOption('showAlpha') === true &&
(returnValue as unknown as string).charAt(0) === '#' (returnValue as unknown as string).charAt(0) === '#'
) { ) {
// Convert the value to rgba that el-color-picker can display it correctly // Convert the value to rgba that el-color-picker can display it correctly
@@ -273,10 +350,8 @@ const expressionDisplayValue = computed(() => {
return `${displayValue.value ?? ''}`; return `${displayValue.value ?? ''}`;
}); });
const isModelValueExpression = computed(() => isValueExpression(props.parameter, props.modelValue));
const dependentParametersValues = computed<string | null>(() => { const dependentParametersValues = computed<string | null>(() => {
const loadOptionsDependsOn = getArgument<string[] | undefined>('loadOptionsDependsOn'); const loadOptionsDependsOn = getTypeOption<string[] | undefined>('loadOptionsDependsOn');
if (loadOptionsDependsOn === undefined) { if (loadOptionsDependsOn === undefined) {
return null; return null;
@@ -293,32 +368,13 @@ const dependentParametersValues = computed<string | null>(() => {
} }
return returnValues.join('|'); return returnValues.join('|');
} catch (error) { } catch {
return null; return null;
} }
}); });
const node = computed(() => ndvStore.activeNode ?? undefined);
const nodeType = computed(
() => node.value && nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion),
);
const displayTitle = computed<string>(() => {
const interpolation = { interpolate: { shortPath: shortPath.value } };
if (getIssues.value.length && isModelValueExpression.value) {
return i18n.baseText('parameterInput.parameterHasIssuesAndExpression', interpolation);
} else if (getIssues.value.length && !isModelValueExpression.value) {
return i18n.baseText('parameterInput.parameterHasIssues', interpolation);
} else if (!getIssues.value.length && isModelValueExpression.value) {
return i18n.baseText('parameterInput.parameterHasExpression', interpolation);
}
return i18n.baseText('parameterInput.parameter', interpolation);
});
const getStringInputType = computed(() => { const getStringInputType = computed(() => {
if (getArgument('password') === true) { if (getTypeOption('password') === true) {
return 'password'; return 'password';
} }
@@ -370,7 +426,7 @@ const getIssues = computed<string[]>(() => {
let checkValues: string[] = []; let checkValues: string[] = [];
if (!skipCheck(displayValue.value)) { if (!nodeSettingsParameters.shouldSkipParamValidation(displayValue.value)) {
if (Array.isArray(displayValue.value)) { if (Array.isArray(displayValue.value)) {
checkValues = checkValues.concat(displayValue.value); checkValues = checkValues.concat(displayValue.value);
} else { } else {
@@ -380,9 +436,7 @@ const getIssues = computed<string[]>(() => {
for (const checkValue of checkValues) { for (const checkValue of checkValues) {
if (checkValue === null || !validOptions.includes(checkValue)) { if (checkValue === null || !validOptions.includes(checkValue)) {
if (issues.parameters === undefined) { issues.parameters = issues.parameters ?? {};
issues.parameters = {};
}
const issue = i18n.baseText('parameterInput.theValueIsNotSupported', { const issue = i18n.baseText('parameterInput.theValueIsNotSupported', {
interpolate: { checkValue }, interpolate: { checkValue },
@@ -392,9 +446,7 @@ const getIssues = computed<string[]>(() => {
} }
} }
} else if (remoteParameterOptionsLoadingIssues.value !== null && !isModelValueExpression.value) { } else if (remoteParameterOptionsLoadingIssues.value !== null && !isModelValueExpression.value) {
if (issues.parameters === undefined) { issues.parameters = issues.parameters ?? {};
issues.parameters = {};
}
issues.parameters[props.parameter.name] = [ issues.parameters[props.parameter.name] = [
`There was a problem loading the parameter options from server: "${remoteParameterOptionsLoadingIssues.value}"`, `There was a problem loading the parameter options from server: "${remoteParameterOptionsLoadingIssues.value}"`,
]; ];
@@ -406,9 +458,7 @@ const getIssues = computed<string[]>(() => {
); );
if (isSelectedArchived) { if (isSelectedArchived) {
if (issues.parameters === undefined) { issues.parameters = issues.parameters ?? {};
issues.parameters = {};
}
const issue = i18n.baseText('parameterInput.selectedWorkflowIsArchived'); const issue = i18n.baseText('parameterInput.selectedWorkflowIsArchived');
issues.parameters[props.parameter.name] = [issue]; issues.parameters[props.parameter.name] = [issue];
} }
@@ -422,6 +472,20 @@ const getIssues = computed<string[]>(() => {
return []; return [];
}); });
const displayTitle = computed<string>(() => {
const interpolation = { interpolate: { shortPath: shortPath.value } };
if (getIssues.value.length && isModelValueExpression.value) {
return i18n.baseText('parameterInput.parameterHasIssuesAndExpression', interpolation);
} else if (getIssues.value.length && !isModelValueExpression.value) {
return i18n.baseText('parameterInput.parameterHasIssues', interpolation);
} else if (!getIssues.value.length && isModelValueExpression.value) {
return i18n.baseText('parameterInput.parameterHasExpression', interpolation);
}
return i18n.baseText('parameterInput.parameter', interpolation);
});
const displayIssues = computed( const displayIssues = computed(
() => () =>
props.parameter.type !== 'credentialsSelect' && props.parameter.type !== 'credentialsSelect' &&
@@ -429,26 +493,6 @@ const displayIssues = computed(
getIssues.value.length > 0, getIssues.value.length > 0,
); );
const editorType = computed<EditorType | 'json' | 'code' | 'cssEditor'>(() => {
return getArgument<EditorType>('editor');
});
const editorIsReadOnly = computed<boolean>(() => {
return getArgument<boolean>('editorIsReadOnly') ?? false;
});
const editorLanguage = computed<CodeNodeEditorLanguage>(() => {
if (editorType.value === 'json' || props.parameter.type === 'json')
return 'json' as CodeNodeEditorLanguage;
return getArgument<CodeNodeEditorLanguage>('editorLanguage') ?? 'javaScript';
});
const parameterOptions = computed(() => {
const options = hasRemoteMethod.value ? remoteParameterOptions.value : props.parameter.options;
const safeOptions = (options ?? []).filter(isValidParameterOption);
return safeOptions;
});
const isSwitch = computed( const isSwitch = computed(
() => props.parameter.type === 'boolean' && !isModelValueExpression.value, () => props.parameter.type === 'boolean' && !isModelValueExpression.value,
); );
@@ -500,28 +544,10 @@ const parameterInputWrapperStyle = computed(() => {
return styles; return styles;
}); });
const hasRemoteMethod = computed<boolean>(() => {
return !!getArgument('loadOptionsMethod') || !!getArgument('loadOptions');
});
const shortPath = computed<string>(() => {
const short = props.path.split('.');
short.shift();
return short.join('.');
});
const parameterId = computed(() => { const parameterId = computed(() => {
return `${node.value?.id ?? crypto.randomUUID()}${props.path}`; return `${node.value?.id ?? crypto.randomUUID()}${props.path}`;
}); });
const isResourceLocatorParameter = computed<boolean>(() => {
return props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector';
});
const isSecretParameter = computed<boolean>(() => {
return getArgument('password') === true;
});
const remoteParameterOptionsKeys = computed<string[]>(() => { const remoteParameterOptionsKeys = computed<string[]>(() => {
return (remoteParameterOptions.value || []).map((o) => o.name); return (remoteParameterOptions.value || []).map((o) => o.name);
}); });
@@ -530,28 +556,6 @@ const shouldRedactValue = computed<boolean>(() => {
return getStringInputType.value === 'password' || props.isForCredential; return getStringInputType.value === 'password' || props.isForCredential;
}); });
const modelValueString = computed<string>(() => {
return props.modelValue as string;
});
const modelValueResourceLocator = computed<INodeParameterResourceLocator>(() => {
return props.modelValue as INodeParameterResourceLocator;
});
const modelValueExpressionEdit = computed<string>(() => {
return isResourceLocatorParameter.value && typeof props.modelValue !== 'string'
? props.modelValue
? ((props.modelValue as INodeParameterResourceLocator).value as string)
: ''
: (props.modelValue as string);
});
const editorRows = computed(() => getArgument<number>('rows'));
const codeEditorMode = computed<CodeExecutionMode>(() => {
return node.value?.parameters.mode as CodeExecutionMode;
});
const isCodeNode = computed( const isCodeNode = computed(
() => !!node.value && NODES_USING_CODE_NODE_EDITOR.includes(node.value.type), () => !!node.value && NODES_USING_CODE_NODE_EDITOR.includes(node.value.type),
); );
@@ -564,7 +568,7 @@ const isInputTypeNumber = computed(() => props.parameter.type === 'number');
const isInputDataEmpty = computed(() => ndvStore.isInputPanelEmpty); const isInputDataEmpty = computed(() => ndvStore.isInputPanelEmpty);
const isDropDisabled = computed( const isDropDisabled = computed(
() => () =>
props.parameter.noDataExpression || props.parameter.noDataExpression === true ||
props.isReadOnly || props.isReadOnly ||
isResourceLocatorParameter.value || isResourceLocatorParameter.value ||
isModelValueExpression.value, isModelValueExpression.value,
@@ -581,18 +585,7 @@ const showDragnDropTip = computed(
!props.isForCredential, !props.isForCredential,
); );
const shouldCaptureForPosthog = computed(() => { const shouldCaptureForPosthog = computed(() => node.value?.type === AI_TRANSFORM_NODE_TYPE);
if (node.value?.type) {
return [AI_TRANSFORM_NODE_TYPE].includes(node.value?.type);
}
return false;
});
function isValidParameterOption(
option: INodePropertyOptions | INodeProperties | INodePropertyCollection,
): option is INodePropertyOptions {
return 'value' in option && isPresent(option.value) && isPresent(option.name);
}
function isRemoteParameterOption(option: INodePropertyOptions) { function isRemoteParameterOption(option: INodePropertyOptions) {
return remoteParameterOptionsKeys.value.includes(option.name); return remoteParameterOptionsKeys.value.includes(option.name);
@@ -612,13 +605,6 @@ function credentialSelected(updateInformation: INodeUpdatePropertiesInformation)
void externalHooks.run('nodeSettings.credentialSelected', { updateInformation }); void externalHooks.run('nodeSettings.credentialSelected', { updateInformation });
} }
/**
* Check whether a param value must be skipped when collecting node param issues for validation.
*/
function skipCheck(value: string | number | boolean | null) {
return typeof value === 'string' && value.includes(CUSTOM_API_CALL_KEY);
}
function getPlaceholder(): string { function getPlaceholder(): string {
return props.isForCredential return props.isForCredential
? i18n.credText(uiStore.activeCredentialType).placeholder(props.parameter) ? i18n.credText(uiStore.activeCredentialType).placeholder(props.parameter)
@@ -662,8 +648,8 @@ async function loadRemoteParameterOptions() {
props.parameter, props.parameter,
currentNodeParameters, currentNodeParameters,
) as INodeParameters; ) as INodeParameters;
const loadOptionsMethod = getArgument<string | undefined>('loadOptionsMethod'); const loadOptionsMethod = getTypeOption<string | undefined>('loadOptionsMethod');
const loadOptions = getArgument<ILoadOptions | undefined>('loadOptions'); const loadOptions = getTypeOption<ILoadOptions | undefined>('loadOptions');
const options = await nodeTypesStore.getNodeParameterOptions({ const options = await nodeTypesStore.getNodeParameterOptions({
nodeTypeAndVersion: { nodeTypeAndVersion: {
@@ -678,8 +664,12 @@ async function loadRemoteParameterOptions() {
}); });
remoteParameterOptions.value = remoteParameterOptions.value.concat(options); remoteParameterOptions.value = remoteParameterOptions.value.concat(options);
} catch (error) { } catch (error: unknown) {
remoteParameterOptionsLoadingIssues.value = error.message; if (error instanceof Error) {
remoteParameterOptionsLoadingIssues.value = error.message;
} else {
remoteParameterOptionsLoadingIssues.value = String(error);
}
} }
remoteParameterOptionsLoading.value = false; remoteParameterOptionsLoading.value = false;
@@ -717,12 +707,14 @@ function trackExpressionEditOpen() {
} }
} }
async function closeTextEditDialog() { function closeTextEditDialog() {
textEditDialogVisible.value = false; textEditDialogVisible.value = false;
editDialogClosing.value = true; editDialogClosing.value = true;
void nextTick().then(() => { void nextTick().then(() => {
inputField.value?.blur?.(); if (isBlurrableEl(inputField.value)) {
inputField.value.blur();
}
editDialogClosing.value = false; editDialogClosing.value = false;
}); });
} }
@@ -739,10 +731,179 @@ function displayEditDialog() {
} }
} }
function getArgument<T = string | number | boolean | undefined>(argumentName: string): T { function openExpressionEditorModal() {
return props.parameter.typeOptions?.[argumentName]; if (!isModelValueExpression.value) return;
expressionEditDialogVisible.value = true;
trackExpressionEditOpen();
} }
function selectInput() {
if (inputField.value) {
if (isSelectableEl(inputField.value)) {
inputField.value.select();
}
}
}
async function setFocus() {
if (['json'].includes(props.parameter.type) && getTypeOption('alwaysOpenEditWindow')) {
displayEditDialog();
return;
}
if (node.value) {
// When an event like mouse-click removes the active node while
// editing is active it does not know where to save the value to.
// For that reason do we save the node-name here. We could probably
// also just do that once on load but if Vue decides for some reason to
// reuse the input it could have the wrong value so lets set it everytime
// just to be sure
nodeName.value = node.value.name;
}
await nextTick();
if (inputField.value) {
if (hasFocusOnInput(inputField.value)) {
inputField.value.focusOnInput();
} else if (isFocusableEl(inputField.value)) {
inputField.value.focus();
}
isFocused.value = true;
}
emit('focus');
}
function rgbaToHex(value: string): string | null {
// Convert rgba to hex from: https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
const valueMatch = value.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*(\d+(\.\d+)?)\)$/);
if (valueMatch === null) {
// TODO: Display something if value is not valid
return null;
}
const [r, g, b, a] = valueMatch.splice(1, 4).map((v) => Number(v));
return (
'#' +
((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1) +
((1 << 8) + Math.floor((1 - a) * 255)).toString(16).slice(1)
);
}
function onTextInputChange(value: string) {
const parameterData = {
node: node.value ? node.value.name : nodeName.value,
name: props.path,
value,
};
emit('textInput', parameterData);
}
function trackWorkflowInputModeEvent(value: string) {
const telemetryValuesMap: Record<string, string> = {
workflowInputs: 'fields',
jsonExample: 'json',
passthrough: 'all',
};
telemetry.track('User chose input data mode', {
option: telemetryValuesMap[value],
workflow_id: workflowsStore.workflowId,
node_id: node.value?.id,
});
}
function valueChanged(untypedValue: unknown) {
if (remoteParameterOptionsLoading.value) {
return;
}
const oldValue = get(node.value, props.path) as unknown;
if (oldValue !== undefined && oldValue === untypedValue) {
// Skip emit if value hasn't changed
return;
}
let value: NodeParameterValueType;
if (untypedValue instanceof Date) {
value = untypedValue.toISOString();
} else if (
typeof untypedValue === 'string' ||
typeof untypedValue === 'number' ||
typeof untypedValue === 'boolean' ||
untypedValue === null ||
Array.isArray(untypedValue)
) {
value = untypedValue;
} else if (typeof untypedValue === 'object' && untypedValue !== null && '__rl' in untypedValue) {
// likely INodeParameterResourceLocator
value = untypedValue as NodeParameterValueType;
} else {
// fallback
value = untypedValue as NodeParameterValueType;
}
const isSpecializedEditor = props.parameter.typeOptions?.editor !== undefined;
if (
!oldValue &&
oldValue !== undefined &&
shouldConvertToExpression(value, isSpecializedEditor)
) {
// if empty old value and updated value has an expression, add '=' prefix to switch to expression mode
value = '=' + value;
}
if (props.parameter.name === 'nodeCredentialType') {
activeCredentialType.value = value as string;
}
value = completeExpressionSyntax(value, isSpecializedEditor);
if (
props.parameter.type === 'color' &&
getTypeOption('showAlpha') === true &&
value !== null &&
value !== undefined &&
(value as string).toString().charAt(0) !== '#'
) {
const newValue = rgbaToHex(value as string);
if (newValue !== null) {
tempValue.value = newValue;
value = newValue;
}
}
const parameterData: IUpdateInformation = {
node: node.value ? node.value.name : nodeName.value,
name: props.path,
value,
};
emit('update', parameterData);
if (props.parameter.name === 'operation' || props.parameter.name === 'mode') {
telemetry.track('User set node operation or mode', {
workflow_id: workflowsStore.workflowId,
node_type: node.value?.type,
resource: node.value?.parameters.resource,
is_custom: value === CUSTOM_API_CALL_KEY,
push_ref: ndvStore.pushRef,
parameter: props.parameter.name,
});
}
// Track workflow input data mode change
const isWorkflowInputParameter =
props.parameter.name === 'inputSource' && props.parameter.default === 'workflowInputs';
if (isWorkflowInputParameter) {
trackWorkflowInputModeEvent(value as string);
}
}
const valueChangedDebounced = debounce(valueChanged, { debounceTime: 100 });
function expressionUpdated(value: string) { function expressionUpdated(value: string) {
const val: NodeParameterValueType = isResourceLocatorParameter.value const val: NodeParameterValueType = isResourceLocatorParameter.value
? { __rl: true, value, mode: modelValueResourceLocator.value.mode } ? { __rl: true, value, mode: modelValueResourceLocator.value.mode }
@@ -750,13 +911,6 @@ function expressionUpdated(value: string) {
valueChanged(val); valueChanged(val);
} }
function openExpressionEditorModal() {
if (!isModelValueExpression.value) return;
expressionEditDialogVisible.value = true;
trackExpressionEditOpen();
}
function onBlur() { function onBlur() {
emit('blur'); emit('blur');
isFocused.value = false; isFocused.value = false;
@@ -800,236 +954,50 @@ function onResourceLocatorDrop(data: string) {
emit('drop', data); emit('drop', data);
} }
function selectInput() {
const inputRef = inputField.value;
if (inputRef) {
if ('select' in inputRef) {
inputRef.select();
}
}
}
async function setFocus() {
if (['json'].includes(props.parameter.type) && getArgument('alwaysOpenEditWindow')) {
displayEditDialog();
return;
}
if (node.value) {
// When an event like mouse-click removes the active node while
// editing is active it does not know where to save the value to.
// For that reason do we save the node-name here. We could probably
// also just do that once on load but if Vue decides for some reason to
// reuse the input it could have the wrong value so lets set it everytime
// just to be sure
nodeName.value = node.value.name;
}
await nextTick();
const inputRef = inputField.value;
if (inputRef) {
if ('focusOnInput' in inputRef) {
inputRef.focusOnInput();
} else if (inputRef.focus) {
inputRef.focus();
}
isFocused.value = true;
}
emit('focus');
}
function rgbaToHex(value: string): string | null {
// Convert rgba to hex from: https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
const valueMatch = value.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*(\d+(\.\d+)?)\)$/);
if (valueMatch === null) {
// TODO: Display something if value is not valid
return null;
}
const [r, g, b, a] = valueMatch.splice(1, 4).map((v) => Number(v));
return (
'#' +
((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1) +
((1 << 8) + Math.floor((1 - a) * 255)).toString(16).slice(1)
);
}
function onTextInputChange(value: string) {
const parameterData = {
node: node.value ? node.value.name : nodeName.value,
name: props.path,
value,
};
emit('textInput', parameterData);
}
const valueChangedDebounced = debounce(valueChanged, { debounceTime: 100 });
function onUpdateTextInput(value: string) { function onUpdateTextInput(value: string) {
valueChanged(value); valueChanged(value);
onTextInputChange(value); onTextInputChange(value);
} }
function valueChanged(value: NodeParameterValueType | {} | Date) {
if (remoteParameterOptionsLoading.value) {
return;
}
const oldValue = get(node.value, props.path);
if (oldValue !== undefined && oldValue === value) {
// Only update the value if it has changed
return;
}
const isSpecializedEditor = props.parameter.typeOptions?.editor !== undefined;
if (
!oldValue &&
oldValue !== undefined &&
shouldConvertToExpression(value, isSpecializedEditor)
) {
// if empty old value and updated value has an expression, add '=' prefix to switch to expression mode
value = '=' + value;
}
if (props.parameter.name === 'nodeCredentialType') {
activeCredentialType.value = value as string;
}
value = completeExpressionSyntax(value, isSpecializedEditor);
if (value instanceof Date) {
value = value.toISOString();
}
if (
props.parameter.type === 'color' &&
getArgument('showAlpha') === true &&
value !== null &&
value !== undefined &&
(value as string).toString().charAt(0) !== '#'
) {
const newValue = rgbaToHex(value as string);
if (newValue !== null) {
tempValue.value = newValue;
value = newValue;
}
}
const parameterData = {
node: node.value ? node.value.name : nodeName.value,
name: props.path,
value,
};
emit('update', parameterData);
if (props.parameter.name === 'operation' || props.parameter.name === 'mode') {
telemetry.track('User set node operation or mode', {
workflow_id: workflowsStore.workflowId,
node_type: node.value?.type,
resource: node.value?.parameters.resource,
is_custom: value === CUSTOM_API_CALL_KEY,
push_ref: ndvStore.pushRef,
parameter: props.parameter.name,
});
}
// Track workflow input data mode change
const isWorkflowInputParameter =
props.parameter.name === 'inputSource' && props.parameter.default === 'workflowInputs';
if (isWorkflowInputParameter) {
trackWorkflowInputModeEvent(value as string);
}
}
function trackWorkflowInputModeEvent(value: string) {
const telemetryValuesMap: Record<string, string> = {
workflowInputs: 'fields',
jsonExample: 'json',
passthrough: 'all',
};
telemetry.track('User chose input data mode', {
option: telemetryValuesMap[value],
workflow_id: workflowsStore.workflowId,
node_id: node.value?.id,
});
}
async function optionSelected(command: string) { async function optionSelected(command: string) {
const prevValue = props.modelValue; const prevValue = props.modelValue;
if (command === 'resetValue') { switch (command) {
valueChanged(props.parameter.default); case 'resetValue':
} else if (command === 'addExpression') { return valueChanged(props.parameter.default);
if (isResourceLocatorParameter.value) {
if (isResourceLocatorValue(props.modelValue)) { case 'addExpression':
valueChanged({ valueChanged(formatAsExpression(props.modelValue, props.parameter.type));
__rl: true, await setFocus();
value: `=${props.modelValue.value}`, break;
mode: props.modelValue.mode,
}); case 'removeExpression':
} else { isFocused.value = false;
valueChanged({ __rl: true, value: `=${props.modelValue}`, mode: '' }); valueChanged(
parseFromExpression(
props.modelValue,
props.expressionEvaluated,
props.parameter.type,
props.parameter.default,
parameterOptions.value,
),
);
break;
case 'refreshOptions':
if (isResourceLocatorParameter.value) {
props.eventBus.emit('refreshList');
} }
} else if ( void loadRemoteParameterOptions();
props.parameter.type === 'number' && return;
(!props.modelValue || props.modelValue === '[Object: null]')
) {
valueChanged('={{ 0 }}');
} else if (props.parameter.type === 'multiOptions') {
valueChanged(`={{ ${JSON.stringify(props.modelValue)} }}`);
} else if (
props.parameter.type === 'number' ||
props.parameter.type === 'boolean' ||
typeof props.modelValue !== 'string'
) {
valueChanged(`={{ ${props.modelValue} }}`);
} else {
valueChanged(`=${props.modelValue}`);
}
await setFocus(); case 'formatHtml':
} else if (command === 'removeExpression') { htmlEditorEventBus.emit('format-html');
let value = props.expressionEvaluated; return;
isFocused.value = false; case 'focus':
nodeSettingsParameters.handleFocus(node.value, props.path, props.parameter);
if (props.parameter.type === 'multiOptions' && typeof value === 'string') { return;
value = (value || '')
.split(',')
.filter((valueItem) =>
(parameterOptions.value ?? []).find((option) => option.value === valueItem),
);
}
if (isResourceLocatorParameter.value && isResourceLocatorValue(props.modelValue)) {
valueChanged({ __rl: true, value, mode: props.modelValue.mode });
} else {
let newValue: NodeParameterValueType | {} = typeof value !== 'undefined' ? value : null;
if (props.parameter.type === 'string') {
// Strip the '=' from the beginning
newValue = modelValueString.value
? modelValueString.value.toString().replace(/^=+/, '')
: null;
} else if (newValue === null) {
// Invalid expressions land here
if (['number', 'boolean'].includes(props.parameter.type)) {
newValue = props.parameter.default;
}
}
valueChanged(newValue);
}
} else if (command === 'refreshOptions') {
if (isResourceLocatorParameter.value) {
props.eventBus.emit('refreshList');
}
void loadRemoteParameterOptions();
} else if (command === 'formatHtml') {
htmlEditorEventBus.emit('format-html');
} }
if (node.value && (command === 'addExpression' || command === 'removeExpression')) { if (node.value && (command === 'addExpression' || command === 'removeExpression')) {
@@ -1045,22 +1013,6 @@ async function optionSelected(command: string) {
telemetry.track('User switched parameter mode', telemetryPayload); telemetry.track('User switched parameter mode', telemetryPayload);
void externalHooks.run('parameterInput.modeSwitch', telemetryPayload); void externalHooks.run('parameterInput.modeSwitch', telemetryPayload);
} }
if (node.value && command === 'focus') {
focusPanelStore.setFocusedNodeParameter({
nodeId: node.value.id,
parameterPath: props.path,
parameter: props.parameter,
});
if (ndvStore.activeNode) {
ndvStore.activeNodeName = null;
// TODO: check what this does - close method on NodeDetailsView
ndvStore.resetNDVPushRef();
}
focusPanelStore.focusPanelActive = true;
}
} }
onMounted(() => { onMounted(() => {
@@ -1078,7 +1030,7 @@ onMounted(() => {
if ( if (
props.parameter.type === 'color' && props.parameter.type === 'color' &&
getArgument('showAlpha') === true && getTypeOption('showAlpha') === true &&
displayValue.value !== null && displayValue.value !== null &&
displayValue.value.toString().charAt(0) !== '#' displayValue.value.toString().charAt(0) !== '#'
) { ) {
@@ -1090,6 +1042,7 @@ onMounted(() => {
void externalHooks.run('parameterInput.mount', { void externalHooks.run('parameterInput.mount', {
parameter: props.parameter, parameter: props.parameter,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unnecessary-type-assertion
inputFieldRef: inputField.value as InstanceType<typeof N8nInput>, inputFieldRef: inputField.value as InstanceType<typeof N8nInput>,
}); });
}); });
@@ -1148,8 +1101,8 @@ watch(dependentParametersValues, async () => {
watch( watch(
() => props.modelValue, () => props.modelValue,
async () => { () => {
if (props.parameter.type === 'color' && getArgument('showAlpha') === true) { if (props.parameter.type === 'color' && getTypeOption('showAlpha') === true) {
// Do not set for color with alpha else wrong value gets displayed in field // Do not set for color with alpha else wrong value gets displayed in field
return; return;
} }
@@ -1344,7 +1297,7 @@ onUpdated(async () => {
<SqlEditor <SqlEditor
v-else-if="editorType === 'sqlEditor' && codeEditDialogVisible" v-else-if="editorType === 'sqlEditor' && codeEditDialogVisible"
:model-value="modelValueString" :model-value="modelValueString"
:dialect="getArgument('sqlDialect')" :dialect="getTypeOption('sqlDialect')"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
fullscreen fullscreen
@@ -1459,7 +1412,7 @@ onUpdated(async () => {
<SqlEditor <SqlEditor
v-else-if="editorType === 'sqlEditor'" v-else-if="editorType === 'sqlEditor'"
:model-value="modelValueString" :model-value="modelValueString"
:dialect="getArgument('sqlDialect')" :dialect="getTypeOption('sqlDialect')"
:is-read-only="isReadOnly" :is-read-only="isReadOnly"
:rows="editorRows" :rows="editorRows"
@update:model-value="valueChangedDebounced" @update:model-value="valueChangedDebounced"
@@ -1588,7 +1541,7 @@ onUpdated(async () => {
:model-value="displayValue" :model-value="displayValue"
:disabled="isReadOnly" :disabled="isReadOnly"
:title="displayTitle" :title="displayTitle"
:show-alpha="getArgument('showAlpha')" :show-alpha="getTypeOption('showAlpha')"
@focus="setFocus" @focus="setFocus"
@blur="onBlur" @blur="onBlur"
@update:model-value="valueChanged" @update:model-value="valueChanged"
@@ -1634,9 +1587,9 @@ onUpdated(async () => {
:size="inputSize" :size="inputSize"
:model-value="displayValue" :model-value="displayValue"
:controls="false" :controls="false"
:max="getArgument('maxValue')" :max="getTypeOption('maxValue')"
:min="getArgument('minValue')" :min="getTypeOption('minValue')"
:precision="getArgument('numberPrecision')" :precision="getTypeOption('numberPrecision')"
:disabled="isReadOnly" :disabled="isReadOnly"
:class="{ 'ph-no-capture': shouldRedactValue }" :class="{ 'ph-no-capture': shouldRedactValue }"
:title="displayTitle" :title="displayTitle"

View File

@@ -1,40 +1,23 @@
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { useNDVStore } from '@/stores/ndv.store';
import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNodeSettingsParameters } from './useNodeSettingsParameters'; import { useNodeSettingsParameters } from './useNodeSettingsParameters';
import type { INodeProperties } from 'n8n-workflow';
import type { MockedStore } from '@/__tests__/utils';
import { mockedStore } from '@/__tests__/utils';
import type { INodeUi } from '@/Interface';
describe('useNodeSettingsParameters', () => { describe('useNodeSettingsParameters', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
});
afterEach(() => {
vi.clearAllMocks();
});
describe('nameIsParameter', () => {
it.each([
['', false],
['parameters', false],
['parameters.', true],
['parameters.path.to.some', true],
['', false],
])('%s should be %s', (input, expected) => {
const { nameIsParameter } = useNodeSettingsParameters();
const result = nameIsParameter({ name: input } as never);
expect(result).toBe(expected);
});
it('should reject path on other input', () => {
const { nameIsParameter } = useNodeSettingsParameters();
const result = nameIsParameter({
name: 'aName',
value: 'parameters.path.to.parameters',
} as never);
expect(result).toBe(false);
});
});
describe('setValue', () => { describe('setValue', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
});
afterEach(() => {
vi.clearAllMocks();
});
it('mutates nodeValues as expected', () => { it('mutates nodeValues as expected', () => {
const nodeSettingsParameters = useNodeSettingsParameters(); const nodeSettingsParameters = useNodeSettingsParameters();
@@ -66,4 +49,76 @@ describe('useNodeSettingsParameters', () => {
expect(nodeSettingsParameters.nodeValues.value.newProperty).toBe('newValue'); expect(nodeSettingsParameters.nodeValues.value.newProperty).toBe('newValue');
}); });
}); });
describe('handleFocus', () => {
let ndvStore: MockedStore<typeof useNDVStore>;
let focusPanelStore: MockedStore<typeof useFocusPanelStore>;
beforeEach(() => {
vi.clearAllMocks();
ndvStore = mockedStore(useNDVStore);
focusPanelStore = mockedStore(useFocusPanelStore);
ndvStore.activeNode = {
id: '123',
name: 'myParam',
parameters: {},
position: [0, 0],
type: 'test',
typeVersion: 1,
};
ndvStore.activeNodeName = 'Node1';
ndvStore.setActiveNodeName = vi.fn();
ndvStore.resetNDVPushRef = vi.fn();
focusPanelStore.setFocusedNodeParameter = vi.fn();
focusPanelStore.focusPanelActive = false;
});
it('sets focused node parameter and activates panel', () => {
const { handleFocus } = useNodeSettingsParameters();
const node: INodeUi = {
id: '1',
name: 'Node1',
position: [0, 0],
typeVersion: 1,
type: 'test',
parameters: {},
};
const path = 'parameters.foo';
const parameter: INodeProperties = {
name: 'foo',
displayName: 'Foo',
type: 'string',
default: '',
};
handleFocus(node, path, parameter);
expect(focusPanelStore.setFocusedNodeParameter).toHaveBeenCalledWith({
nodeId: node.id,
parameterPath: path,
parameter,
});
expect(focusPanelStore.focusPanelActive).toBe(true);
expect(ndvStore.setActiveNodeName).toHaveBeenCalledWith(null);
expect(ndvStore.resetNDVPushRef).toHaveBeenCalled();
});
it('does nothing if node is undefined', () => {
const { handleFocus } = useNodeSettingsParameters();
const parameter: INodeProperties = {
name: 'foo',
displayName: 'Foo',
type: 'string',
default: '',
};
handleFocus(undefined, 'parameters.foo', parameter);
expect(focusPanelStore.setFocusedNodeParameter).not.toHaveBeenCalled();
});
});
}); });

View File

@@ -1,21 +1,26 @@
import type { IUpdateInformation } from '@/Interface';
import get from 'lodash/get'; import get from 'lodash/get';
import set from 'lodash/set'; import set from 'lodash/set';
import { ref } from 'vue';
import { import {
type INode, type INode,
type INodeParameters, type INodeParameters,
type INodeProperties,
type NodeParameterValue, type NodeParameterValue,
NodeHelpers, NodeHelpers,
deepCopy, deepCopy,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { useTelemetry } from './useTelemetry'; import { useTelemetry } from './useTelemetry';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeHelpers } from './useNodeHelpers'; import { useNodeHelpers } from './useNodeHelpers';
import { useCanvasOperations } from './useCanvasOperations'; import { useCanvasOperations } from './useCanvasOperations';
import { useExternalHooks } from './useExternalHooks'; import { useExternalHooks } from './useExternalHooks';
import { ref } from 'vue'; import type { INodeUi, IUpdateInformation } from '@/Interface';
import { updateDynamicConnections, updateParameterByPath } from '@/utils/nodeSettingsUtils'; import { updateDynamicConnections, updateParameterByPath } from '@/utils/nodeSettingsUtils';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; 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 } from '@/constants';
import { omitKey } from '@/utils/objectUtils';
export function useNodeSettingsParameters() { export function useNodeSettingsParameters() {
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
@@ -43,7 +48,7 @@ export function useNodeSettingsParameters() {
let lastNamePart: string | undefined = nameParts.pop(); let lastNamePart: string | undefined = nameParts.pop();
let isArray = false; let isArray = false;
if (lastNamePart !== undefined && lastNamePart.includes('[')) { if (lastNamePart?.includes('[')) {
// It includes an index so we have to extract it // It includes an index so we have to extract it
const lastNameParts = lastNamePart.match(/(.*)\[(\d+)\]$/); const lastNameParts = lastNamePart.match(/(.*)\[(\d+)\]$/);
if (lastNameParts) { if (lastNameParts) {
@@ -59,8 +64,7 @@ export function useNodeSettingsParameters() {
if (value === null) { if (value === null) {
// Property should be deleted // Property should be deleted
if (lastNamePart) { if (lastNamePart) {
const { [lastNamePart]: removedNodeValue, ...remainingNodeValues } = nodeValues.value; nodeValues.value = omitKey(nodeValues.value, lastNamePart);
nodeValues.value = remainingNodeValues;
} }
} else { } else {
// Value should be set // Value should be set
@@ -78,8 +82,7 @@ export function useNodeSettingsParameters() {
| INodeParameters[]; | INodeParameters[];
if (lastNamePart && !Array.isArray(tempValue)) { if (lastNamePart && !Array.isArray(tempValue)) {
const { [lastNamePart]: removedNodeValue, ...remainingNodeValues } = tempValue; tempValue = omitKey(tempValue, lastNamePart);
tempValue = remainingNodeValues;
} }
if (isArray && Array.isArray(tempValue) && tempValue.length === 0) { if (isArray && Array.isArray(tempValue) && tempValue.length === 0) {
@@ -88,9 +91,7 @@ export function useNodeSettingsParameters() {
lastNamePart = nameParts.pop(); lastNamePart = nameParts.pop();
tempValue = get(nodeValues.value, nameParts.join('.')) as INodeParameters; tempValue = get(nodeValues.value, nameParts.join('.')) as INodeParameters;
if (lastNamePart) { if (lastNamePart) {
const { [lastNamePart]: removedArrayNodeValue, ...remainingArrayNodeValues } = tempValue = omitKey(tempValue, lastNamePart);
tempValue;
tempValue = remainingArrayNodeValues;
} }
} }
} else { } else {
@@ -114,12 +115,6 @@ export function useNodeSettingsParameters() {
nodeValues.value = { ...nodeValues.value }; nodeValues.value = { ...nodeValues.value };
} }
function nameIsParameter(
parameterData: IUpdateInformation,
): parameterData is IUpdateInformation & { name: `parameters.${string}` } {
return parameterData.name.startsWith('parameters.');
}
function updateNodeParameter( function updateNodeParameter(
parameterData: IUpdateInformation & { name: `parameters.${string}` }, parameterData: IUpdateInformation & { name: `parameters.${string}` },
newValue: NodeParameterValue, newValue: NodeParameterValue,
@@ -221,11 +216,36 @@ export function useNodeSettingsParameters() {
telemetry.trackNodeParametersValuesChange(nodeTypeDescription.name, parameterData); telemetry.trackNodeParametersValuesChange(nodeTypeDescription.name, parameterData);
} }
function handleFocus(node: INodeUi | undefined, path: string, parameter: INodeProperties) {
if (!node) return;
const ndvStore = useNDVStore();
const focusPanelStore = useFocusPanelStore();
focusPanelStore.setFocusedNodeParameter({
nodeId: node.id,
parameterPath: path,
parameter,
});
if (ndvStore.activeNode) {
ndvStore.setActiveNodeName(null);
ndvStore.resetNDVPushRef();
}
focusPanelStore.focusPanelActive = true;
}
function shouldSkipParamValidation(value: string | number | boolean | null) {
return typeof value === 'string' && value.includes(CUSTOM_API_CALL_KEY);
}
return { return {
nodeValues, nodeValues,
setValue, setValue,
updateParameterByPath, updateParameterByPath,
updateNodeParameter, updateNodeParameter,
nameIsParameter, handleFocus,
shouldSkipParamValidation,
}; };
} }

View File

@@ -147,8 +147,12 @@ export const completeExpressionSyntax = <T>(value: T, isSpecializedEditor = fals
return value; return value;
}; };
export const shouldConvertToExpression = <T>(value: T, isSpecializedEditor = false): boolean => { export const shouldConvertToExpression = (
value: unknown,
isSpecializedEditor = false,
): value is string => {
if (isSpecializedEditor) return false; if (isSpecializedEditor) return false;
return ( return (
typeof value === 'string' && typeof value === 'string' &&
!value.startsWith('=') && !value.startsWith('=') &&

View File

@@ -5,8 +5,15 @@ import type {
NodeParameterValueType, NodeParameterValueType,
IDataObject, IDataObject,
INodeTypeDescription, INodeTypeDescription,
INodePropertyOptions,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { updateDynamicConnections, updateParameterByPath } from './nodeSettingsUtils'; import {
updateDynamicConnections,
updateParameterByPath,
nameIsParameter,
formatAsExpression,
parseFromExpression,
} from './nodeSettingsUtils';
import { SWITCH_NODE_TYPE } from '@/constants'; import { SWITCH_NODE_TYPE } from '@/constants';
import type { INodeUi, IUpdateInformation } from '@/Interface'; import type { INodeUi, IUpdateInformation } from '@/Interface';
@@ -14,6 +21,7 @@ describe('updateDynamicConnections', () => {
afterAll(() => { afterAll(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should remove extra outputs when the number of outputs decreases', () => { it('should remove extra outputs when the number of outputs decreases', () => {
const node = mock<INodeUi>({ const node = mock<INodeUi>({
name: 'TestNode', name: 'TestNode',
@@ -282,3 +290,109 @@ describe('updateParameterByPath', () => {
expect(nodeParameters.arrayParam).toEqual(['value1', 'value3']); expect(nodeParameters.arrayParam).toEqual(['value1', 'value3']);
}); });
}); });
describe('nameIsParameter', () => {
it.each([
['', false],
['parameters', false],
['parameters.', true],
['parameters.path.to.some', true],
['', false],
])('%s should be %s', (input, expected) => {
const result = nameIsParameter({ name: input } as never);
expect(result).toBe(expected);
});
it('should reject path on other input', () => {
const result = nameIsParameter({
name: 'aName',
value: 'parameters.path.to.parameters',
} as never);
expect(result).toBe(false);
});
});
describe('formatAsExpression', () => {
it('wraps string value with "="', () => {
expect(formatAsExpression('foo', 'string')).toBe('=foo');
});
it('wraps number value with "={{ }}"', () => {
expect(formatAsExpression(42, 'number')).toBe('={{ 42 }}');
});
it('wraps boolean value with "={{ }}"', () => {
expect(formatAsExpression(true, 'boolean')).toBe('={{ true }}');
});
it('wraps multiOptions value with "={{ }}" and stringifies', () => {
expect(formatAsExpression(['a', 'b'], 'multiOptions')).toBe('={{ ["a","b"] }}');
});
it('returns "={{ 0 }}" for number with empty value', () => {
expect(formatAsExpression('', 'number')).toBe('={{ 0 }}');
expect(formatAsExpression('[Object: null]', 'number')).toBe('={{ 0 }}');
});
it('wraps non-string, non-number, non-boolean value with "={{ }}"', () => {
expect(formatAsExpression({ foo: 'bar' }, 'string')).toBe('={{ [object Object] }}');
});
it('handles resourceLocator value', () => {
const value = { __rl: true, value: 'abc', mode: 'url' };
expect(formatAsExpression(value, 'resourceLocator')).toEqual({
__rl: true,
value: '=abc',
mode: 'url',
});
});
it('handles resourceLocator value as string', () => {
expect(formatAsExpression('abc', 'resourceLocator')).toEqual({
__rl: true,
value: '=abc',
mode: '',
});
});
});
describe('parseFromExpression', () => {
it('removes expression from multiOptions string value', () => {
const options: INodePropertyOptions[] = [
{ name: 'Option A', value: 'a' },
{ name: 'Option B', value: 'b' },
{ name: 'Option C', value: 'c' },
];
expect(parseFromExpression('', 'a,b,c', 'multiOptions', [], options)).toEqual(['a', 'b', 'c']);
expect(parseFromExpression('', 'a,x', 'multiOptions', [], options)).toEqual(['a']);
});
it('removes expression from resourceLocator value', () => {
const modelValue = { __rl: true, value: '=abc', mode: 'url' };
expect(parseFromExpression(modelValue, 'abc', 'resourceLocator', '', [])).toEqual({
__rl: true,
value: 'abc',
mode: 'url',
});
});
it('removes leading "=" from string parameter', () => {
expect(parseFromExpression('=foo', undefined, 'string', '', [])).toBe('foo');
expect(parseFromExpression('==bar', undefined, 'string', '', [])).toBe('bar');
expect(parseFromExpression('', undefined, 'string', '', [])).toBeNull();
});
it('returns value if defined and not string/resourceLocator/multiOptions', () => {
expect(parseFromExpression(123, 456, 'number', 0, [])).toBe(456);
expect(parseFromExpression(true, false, 'boolean', true, [])).toBe(false);
});
it('returns defaultValue for number/boolean if value is undefined', () => {
expect(parseFromExpression(123, undefined, 'number', 0, [])).toBe(0);
expect(parseFromExpression(true, undefined, 'boolean', false, [])).toBe(false);
});
it('returns null for other types if value is undefined', () => {
expect(parseFromExpression({}, undefined, 'json', null, [])).toBeNull();
});
});

View File

@@ -8,10 +8,15 @@ import {
type INode, type INode,
type INodeParameters, type INodeParameters,
type NodeParameterValue, type NodeParameterValue,
type INodeProperties,
type INodePropertyOptions,
type INodePropertyCollection,
type NodePropertyTypes,
isINodePropertyCollectionList, isINodePropertyCollectionList,
isINodePropertiesList, isINodePropertiesList,
isINodePropertyOptionsList, isINodePropertyOptionsList,
displayParameter, displayParameter,
isResourceLocatorValue,
} from 'n8n-workflow'; } from 'n8n-workflow';
import type { INodeUi, IUpdateInformation } from '@/Interface'; import type { INodeUi, IUpdateInformation } from '@/Interface';
import { SWITCH_NODE_TYPE } from '@/constants'; import { SWITCH_NODE_TYPE } from '@/constants';
@@ -21,6 +26,7 @@ import set from 'lodash/set';
import unset from 'lodash/unset'; import unset from 'lodash/unset';
import { captureException } from '@sentry/vue'; import { captureException } from '@sentry/vue';
import { isPresent } from './typesUtils';
export function updateDynamicConnections( export function updateDynamicConnections(
node: INodeUi, node: INodeUi,
@@ -235,3 +241,99 @@ export function updateParameterByPath(
return parameterPath; return parameterPath;
} }
export function getParameterTypeOption<T = string | number | boolean | undefined>(
parameter: INodeProperties,
optionName: string,
): T {
return parameter.typeOptions?.[optionName] as T;
}
export function isResourceLocatorParameterType(type: NodePropertyTypes) {
return type === 'resourceLocator' || type === 'workflowSelector';
}
export function isValidParameterOption(
option: INodePropertyOptions | INodeProperties | INodePropertyCollection,
): option is INodePropertyOptions {
return 'value' in option && isPresent(option.value) && isPresent(option.name);
}
export function nameIsParameter(
parameterData: IUpdateInformation,
): parameterData is IUpdateInformation & { name: `parameters.${string}` } {
return parameterData.name.startsWith('parameters.');
}
export function formatAsExpression(
value: NodeParameterValueType,
parameterType: NodePropertyTypes,
) {
if (isResourceLocatorParameterType(parameterType)) {
if (isResourceLocatorValue(value)) {
return {
__rl: true,
value: `=${value.value}`,
mode: value.mode,
};
}
return { __rl: true, value: `=${value as string}`, mode: '' };
}
const isNumber = parameterType === 'number';
const isBoolean = parameterType === 'boolean';
const isMultiOptions = parameterType === 'multiOptions';
if (isNumber && (!value || value === '[Object: null]')) {
return '={{ 0 }}';
}
if (isMultiOptions) {
return `={{ ${JSON.stringify(value)} }}`;
}
if (isNumber || isBoolean || typeof value !== 'string') {
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- stringified intentionally
return `={{ ${String(value)} }}`;
}
return `=${value}`;
}
export function parseFromExpression(
currentParameterValue: NodeParameterValueType,
evaluatedExpressionValue: unknown,
parameterType: NodePropertyTypes,
defaultValue: NodeParameterValueType,
parameterOptions: INodePropertyOptions[] = [],
) {
if (parameterType === 'multiOptions' && typeof evaluatedExpressionValue === 'string') {
return evaluatedExpressionValue
.split(',')
.filter((valueItem) => parameterOptions.find((option) => option.value === valueItem));
}
if (
isResourceLocatorParameterType(parameterType) &&
isResourceLocatorValue(currentParameterValue)
) {
return { __rl: true, value: evaluatedExpressionValue, mode: currentParameterValue.mode };
}
if (parameterType === 'string') {
return currentParameterValue
? (currentParameterValue as string).toString().replace(/^=+/, '')
: null;
}
if (typeof evaluatedExpressionValue !== 'undefined') {
return evaluatedExpressionValue;
}
if (['number', 'boolean'].includes(parameterType)) {
return defaultValue;
}
return null;
}

View File

@@ -1,4 +1,10 @@
import { isObjectOrArray, isObject, searchInObject, getObjectSizeInKB } from '@/utils/objectUtils'; import {
isObjectOrArray,
isObject,
searchInObject,
getObjectSizeInKB,
omitKey,
} from '@/utils/objectUtils';
const testData = [1, '', true, null, undefined, new Date(), () => {}].map((value) => [ const testData = [1, '', true, null, undefined, new Date(), () => {}].map((value) => [
value, value,
@@ -154,4 +160,37 @@ describe('objectUtils', () => {
expect(getObjectSizeInKB(obj)).toBe(0.02); expect(getObjectSizeInKB(obj)).toBe(0.02);
}); });
}); });
describe('omitKey', () => {
it('should remove a top-level key from a flat object', () => {
const input = { a: 1, b: 2, c: 3 };
const result = omitKey(input, 'b');
expect(result).toEqual({ a: 1, c: 3 });
});
it('should not mutate the original object', () => {
const input = { a: 1, b: 2 };
const copy = { ...input };
omitKey(input, 'b');
expect(input).toEqual(copy);
});
it('should return the same object if the key does not exist', () => {
const input = { a: 1, b: 2 };
const result = omitKey(input, 'z');
expect(result).toEqual(input);
});
it('should remove a key with an undefined value', () => {
const input = { a: 1, b: undefined };
const result = omitKey(input, 'b');
expect(result).toEqual({ a: 1 });
});
it('should work with nested objects but only remove the top-level key', () => {
const input = { a: { nested: true }, b: 2 };
const result = omitKey(input, 'a');
expect(result).toEqual({ b: 2 });
});
});
}); });

View File

@@ -50,3 +50,10 @@ export const getObjectSizeInKB = (obj: unknown): number => {
); );
} }
}; };
export function omitKey<T extends Record<string, unknown>, S extends string>(
obj: T,
key: S,
): Omit<T, S> {
return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key)) as Omit<T, S>;
}

View File

@@ -170,3 +170,39 @@ export const tryToParseNumber = (value: string): number | string => {
export function isPresent<T>(arg: T): arg is Exclude<T, null | undefined> { export function isPresent<T>(arg: T): arg is Exclude<T, null | undefined> {
return arg !== null && arg !== undefined; return arg !== null && arg !== undefined;
} }
export function isFocusableEl(el: unknown): el is HTMLElement & { focus: () => void } {
return (
typeof el === 'object' &&
el !== null &&
'focus' in el &&
typeof (el as { focus?: unknown }).focus === 'function'
);
}
export function isBlurrableEl(el: unknown): el is HTMLElement & { blur: () => void } {
return (
typeof el === 'object' &&
el !== null &&
'blur' in el &&
typeof (el as { blur?: unknown }).blur === 'function'
);
}
export function isSelectableEl(el: unknown): el is HTMLInputElement | { select: () => void } {
return (
typeof el === 'object' &&
el !== null &&
'select' in el &&
typeof (el as { select?: unknown }).select === 'function'
);
}
export function hasFocusOnInput(el: unknown): el is { focusOnInput: () => void } {
return (
typeof el === 'object' &&
el !== null &&
'focusOnInput' in el &&
typeof (el as { focusOnInput?: unknown }).focusOnInput === 'function'
);
}