mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
refactor(editor): Move optionSelected logic to a composable (#16968)
This commit is contained in:
@@ -31,6 +31,7 @@ import NodeSettingsHeader from '@/components/NodeSettingsHeader.vue';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import NodeExecuteButton from './NodeExecuteButton.vue';
|
||||
import { nameIsParameter } from '@/utils/nodeSettingsUtils';
|
||||
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
@@ -369,7 +370,7 @@ const valueChanged = (parameterData: IUpdateInformation) => {
|
||||
nodeHelpers.updateNodeParameterIssuesByName(_node.name);
|
||||
nodeHelpers.updateNodeCredentialIssuesByName(_node.name);
|
||||
}
|
||||
} else if (nodeSettingsParameters.nameIsParameter(parameterData)) {
|
||||
} else if (nameIsParameter(parameterData)) {
|
||||
// A node parameter changed
|
||||
nodeSettingsParameters.updateNodeParameter(parameterData, newValue, _node, isToolNode.value);
|
||||
} else {
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
INodeParameterResourceLocator,
|
||||
INodeParameters,
|
||||
INodeProperties,
|
||||
INodePropertyCollection,
|
||||
INodePropertyOptions,
|
||||
IParameterLabel,
|
||||
NodeParameterValueType,
|
||||
@@ -37,6 +36,13 @@ import ResourceLocator from '@/components/ResourceLocator/ResourceLocator.vue';
|
||||
import SqlEditor from '@/components/SqlEditor/SqlEditor.vue';
|
||||
import TextEdit from '@/components/TextEdit.vue';
|
||||
|
||||
import {
|
||||
formatAsExpression,
|
||||
getParameterTypeOption,
|
||||
isResourceLocatorParameterType,
|
||||
isValidParameterOption,
|
||||
parseFromExpression,
|
||||
} from '@/utils/nodeSettingsUtils';
|
||||
import { hasExpressionMapping, isValueExpression } from '@/utils/nodeTypesUtils';
|
||||
|
||||
import {
|
||||
@@ -54,23 +60,23 @@ import { useI18n } from '@n8n/i18n';
|
||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
|
||||
import { htmlEditorEventBus } from '@/event-bus';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useSettingsStore } from '@/stores/settings.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 type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
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 { isPresent } from '@/utils/typesUtils';
|
||||
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 };
|
||||
|
||||
@@ -125,6 +131,7 @@ const i18n = useI18n();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const { debounce } = useDebounce();
|
||||
const workflowHelpers = useWorkflowHelpers();
|
||||
const nodeSettingsParameters = useNodeSettingsParameters();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const credentialsStore = useCredentialsStore();
|
||||
@@ -133,7 +140,6 @@ const workflowsStore = useWorkflowsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const uiStore = useUIStore();
|
||||
const focusPanelStore = useFocusPanelStore();
|
||||
|
||||
// ESLint: false positive
|
||||
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
|
||||
@@ -181,6 +187,77 @@ const dateTimePickerOptions = ref({
|
||||
});
|
||||
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(() => {
|
||||
if (remoteParameterOptionsLoadingIssues.value) {
|
||||
if (!nodeType.value || nodeType.value?.codex?.categories?.includes(CORE_NODES_CATEGORY)) {
|
||||
@@ -234,7 +311,7 @@ const displayValue = computed(() => {
|
||||
if (
|
||||
Array.isArray(returnValue) &&
|
||||
props.parameter.type === 'color' &&
|
||||
getArgument('showAlpha') === true &&
|
||||
getTypeOption('showAlpha') === true &&
|
||||
(returnValue as unknown as string).charAt(0) === '#'
|
||||
) {
|
||||
// Convert the value to rgba that el-color-picker can display it correctly
|
||||
@@ -273,10 +350,8 @@ const expressionDisplayValue = computed(() => {
|
||||
return `${displayValue.value ?? ''}`;
|
||||
});
|
||||
|
||||
const isModelValueExpression = computed(() => isValueExpression(props.parameter, props.modelValue));
|
||||
|
||||
const dependentParametersValues = computed<string | null>(() => {
|
||||
const loadOptionsDependsOn = getArgument<string[] | undefined>('loadOptionsDependsOn');
|
||||
const loadOptionsDependsOn = getTypeOption<string[] | undefined>('loadOptionsDependsOn');
|
||||
|
||||
if (loadOptionsDependsOn === undefined) {
|
||||
return null;
|
||||
@@ -293,32 +368,13 @@ const dependentParametersValues = computed<string | null>(() => {
|
||||
}
|
||||
|
||||
return returnValues.join('|');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
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(() => {
|
||||
if (getArgument('password') === true) {
|
||||
if (getTypeOption('password') === true) {
|
||||
return 'password';
|
||||
}
|
||||
|
||||
@@ -370,7 +426,7 @@ const getIssues = computed<string[]>(() => {
|
||||
|
||||
let checkValues: string[] = [];
|
||||
|
||||
if (!skipCheck(displayValue.value)) {
|
||||
if (!nodeSettingsParameters.shouldSkipParamValidation(displayValue.value)) {
|
||||
if (Array.isArray(displayValue.value)) {
|
||||
checkValues = checkValues.concat(displayValue.value);
|
||||
} else {
|
||||
@@ -380,9 +436,7 @@ const getIssues = computed<string[]>(() => {
|
||||
|
||||
for (const checkValue of checkValues) {
|
||||
if (checkValue === null || !validOptions.includes(checkValue)) {
|
||||
if (issues.parameters === undefined) {
|
||||
issues.parameters = {};
|
||||
}
|
||||
issues.parameters = issues.parameters ?? {};
|
||||
|
||||
const issue = i18n.baseText('parameterInput.theValueIsNotSupported', {
|
||||
interpolate: { checkValue },
|
||||
@@ -392,9 +446,7 @@ const getIssues = computed<string[]>(() => {
|
||||
}
|
||||
}
|
||||
} else if (remoteParameterOptionsLoadingIssues.value !== null && !isModelValueExpression.value) {
|
||||
if (issues.parameters === undefined) {
|
||||
issues.parameters = {};
|
||||
}
|
||||
issues.parameters = issues.parameters ?? {};
|
||||
issues.parameters[props.parameter.name] = [
|
||||
`There was a problem loading the parameter options from server: "${remoteParameterOptionsLoadingIssues.value}"`,
|
||||
];
|
||||
@@ -406,9 +458,7 @@ const getIssues = computed<string[]>(() => {
|
||||
);
|
||||
|
||||
if (isSelectedArchived) {
|
||||
if (issues.parameters === undefined) {
|
||||
issues.parameters = {};
|
||||
}
|
||||
issues.parameters = issues.parameters ?? {};
|
||||
const issue = i18n.baseText('parameterInput.selectedWorkflowIsArchived');
|
||||
issues.parameters[props.parameter.name] = [issue];
|
||||
}
|
||||
@@ -422,6 +472,20 @@ const getIssues = computed<string[]>(() => {
|
||||
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(
|
||||
() =>
|
||||
props.parameter.type !== 'credentialsSelect' &&
|
||||
@@ -429,26 +493,6 @@ const displayIssues = computed(
|
||||
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(
|
||||
() => props.parameter.type === 'boolean' && !isModelValueExpression.value,
|
||||
);
|
||||
@@ -500,28 +544,10 @@ const parameterInputWrapperStyle = computed(() => {
|
||||
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(() => {
|
||||
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[]>(() => {
|
||||
return (remoteParameterOptions.value || []).map((o) => o.name);
|
||||
});
|
||||
@@ -530,28 +556,6 @@ const shouldRedactValue = computed<boolean>(() => {
|
||||
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(
|
||||
() => !!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 isDropDisabled = computed(
|
||||
() =>
|
||||
props.parameter.noDataExpression ||
|
||||
props.parameter.noDataExpression === true ||
|
||||
props.isReadOnly ||
|
||||
isResourceLocatorParameter.value ||
|
||||
isModelValueExpression.value,
|
||||
@@ -581,18 +585,7 @@ const showDragnDropTip = computed(
|
||||
!props.isForCredential,
|
||||
);
|
||||
|
||||
const shouldCaptureForPosthog = computed(() => {
|
||||
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);
|
||||
}
|
||||
const shouldCaptureForPosthog = computed(() => node.value?.type === AI_TRANSFORM_NODE_TYPE);
|
||||
|
||||
function isRemoteParameterOption(option: INodePropertyOptions) {
|
||||
return remoteParameterOptionsKeys.value.includes(option.name);
|
||||
@@ -612,13 +605,6 @@ function credentialSelected(updateInformation: INodeUpdatePropertiesInformation)
|
||||
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 {
|
||||
return props.isForCredential
|
||||
? i18n.credText(uiStore.activeCredentialType).placeholder(props.parameter)
|
||||
@@ -662,8 +648,8 @@ async function loadRemoteParameterOptions() {
|
||||
props.parameter,
|
||||
currentNodeParameters,
|
||||
) as INodeParameters;
|
||||
const loadOptionsMethod = getArgument<string | undefined>('loadOptionsMethod');
|
||||
const loadOptions = getArgument<ILoadOptions | undefined>('loadOptions');
|
||||
const loadOptionsMethod = getTypeOption<string | undefined>('loadOptionsMethod');
|
||||
const loadOptions = getTypeOption<ILoadOptions | undefined>('loadOptions');
|
||||
|
||||
const options = await nodeTypesStore.getNodeParameterOptions({
|
||||
nodeTypeAndVersion: {
|
||||
@@ -678,8 +664,12 @@ async function loadRemoteParameterOptions() {
|
||||
});
|
||||
|
||||
remoteParameterOptions.value = remoteParameterOptions.value.concat(options);
|
||||
} catch (error) {
|
||||
remoteParameterOptionsLoadingIssues.value = error.message;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
remoteParameterOptionsLoadingIssues.value = error.message;
|
||||
} else {
|
||||
remoteParameterOptionsLoadingIssues.value = String(error);
|
||||
}
|
||||
}
|
||||
|
||||
remoteParameterOptionsLoading.value = false;
|
||||
@@ -717,12 +707,14 @@ function trackExpressionEditOpen() {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeTextEditDialog() {
|
||||
function closeTextEditDialog() {
|
||||
textEditDialogVisible.value = false;
|
||||
|
||||
editDialogClosing.value = true;
|
||||
void nextTick().then(() => {
|
||||
inputField.value?.blur?.();
|
||||
if (isBlurrableEl(inputField.value)) {
|
||||
inputField.value.blur();
|
||||
}
|
||||
editDialogClosing.value = false;
|
||||
});
|
||||
}
|
||||
@@ -739,10 +731,179 @@ function displayEditDialog() {
|
||||
}
|
||||
}
|
||||
|
||||
function getArgument<T = string | number | boolean | undefined>(argumentName: string): T {
|
||||
return props.parameter.typeOptions?.[argumentName];
|
||||
function openExpressionEditorModal() {
|
||||
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) {
|
||||
const val: NodeParameterValueType = isResourceLocatorParameter.value
|
||||
? { __rl: true, value, mode: modelValueResourceLocator.value.mode }
|
||||
@@ -750,13 +911,6 @@ function expressionUpdated(value: string) {
|
||||
valueChanged(val);
|
||||
}
|
||||
|
||||
function openExpressionEditorModal() {
|
||||
if (!isModelValueExpression.value) return;
|
||||
|
||||
expressionEditDialogVisible.value = true;
|
||||
trackExpressionEditOpen();
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
emit('blur');
|
||||
isFocused.value = false;
|
||||
@@ -800,236 +954,50 @@ function onResourceLocatorDrop(data: string) {
|
||||
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) {
|
||||
valueChanged(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) {
|
||||
const prevValue = props.modelValue;
|
||||
|
||||
if (command === 'resetValue') {
|
||||
valueChanged(props.parameter.default);
|
||||
} else if (command === 'addExpression') {
|
||||
if (isResourceLocatorParameter.value) {
|
||||
if (isResourceLocatorValue(props.modelValue)) {
|
||||
valueChanged({
|
||||
__rl: true,
|
||||
value: `=${props.modelValue.value}`,
|
||||
mode: props.modelValue.mode,
|
||||
});
|
||||
} else {
|
||||
valueChanged({ __rl: true, value: `=${props.modelValue}`, mode: '' });
|
||||
switch (command) {
|
||||
case 'resetValue':
|
||||
return valueChanged(props.parameter.default);
|
||||
|
||||
case 'addExpression':
|
||||
valueChanged(formatAsExpression(props.modelValue, props.parameter.type));
|
||||
await setFocus();
|
||||
break;
|
||||
|
||||
case 'removeExpression':
|
||||
isFocused.value = false;
|
||||
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 (
|
||||
props.parameter.type === 'number' &&
|
||||
(!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}`);
|
||||
}
|
||||
void loadRemoteParameterOptions();
|
||||
return;
|
||||
|
||||
await setFocus();
|
||||
} else if (command === 'removeExpression') {
|
||||
let value = props.expressionEvaluated;
|
||||
case 'formatHtml':
|
||||
htmlEditorEventBus.emit('format-html');
|
||||
return;
|
||||
|
||||
isFocused.value = false;
|
||||
|
||||
if (props.parameter.type === 'multiOptions' && typeof value === 'string') {
|
||||
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');
|
||||
case 'focus':
|
||||
nodeSettingsParameters.handleFocus(node.value, props.path, props.parameter);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.value && (command === 'addExpression' || command === 'removeExpression')) {
|
||||
@@ -1045,22 +1013,6 @@ async function optionSelected(command: string) {
|
||||
telemetry.track('User switched parameter mode', 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(() => {
|
||||
@@ -1078,7 +1030,7 @@ onMounted(() => {
|
||||
|
||||
if (
|
||||
props.parameter.type === 'color' &&
|
||||
getArgument('showAlpha') === true &&
|
||||
getTypeOption('showAlpha') === true &&
|
||||
displayValue.value !== null &&
|
||||
displayValue.value.toString().charAt(0) !== '#'
|
||||
) {
|
||||
@@ -1090,6 +1042,7 @@ onMounted(() => {
|
||||
|
||||
void externalHooks.run('parameterInput.mount', {
|
||||
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>,
|
||||
});
|
||||
});
|
||||
@@ -1148,8 +1101,8 @@ watch(dependentParametersValues, async () => {
|
||||
|
||||
watch(
|
||||
() => 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
|
||||
return;
|
||||
}
|
||||
@@ -1344,7 +1297,7 @@ onUpdated(async () => {
|
||||
<SqlEditor
|
||||
v-else-if="editorType === 'sqlEditor' && codeEditDialogVisible"
|
||||
:model-value="modelValueString"
|
||||
:dialect="getArgument('sqlDialect')"
|
||||
:dialect="getTypeOption('sqlDialect')"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
fullscreen
|
||||
@@ -1459,7 +1412,7 @@ onUpdated(async () => {
|
||||
<SqlEditor
|
||||
v-else-if="editorType === 'sqlEditor'"
|
||||
:model-value="modelValueString"
|
||||
:dialect="getArgument('sqlDialect')"
|
||||
:dialect="getTypeOption('sqlDialect')"
|
||||
:is-read-only="isReadOnly"
|
||||
:rows="editorRows"
|
||||
@update:model-value="valueChangedDebounced"
|
||||
@@ -1588,7 +1541,7 @@ onUpdated(async () => {
|
||||
:model-value="displayValue"
|
||||
:disabled="isReadOnly"
|
||||
:title="displayTitle"
|
||||
:show-alpha="getArgument('showAlpha')"
|
||||
:show-alpha="getTypeOption('showAlpha')"
|
||||
@focus="setFocus"
|
||||
@blur="onBlur"
|
||||
@update:model-value="valueChanged"
|
||||
@@ -1634,9 +1587,9 @@ onUpdated(async () => {
|
||||
:size="inputSize"
|
||||
:model-value="displayValue"
|
||||
:controls="false"
|
||||
:max="getArgument('maxValue')"
|
||||
:min="getArgument('minValue')"
|
||||
:precision="getArgument('numberPrecision')"
|
||||
:max="getTypeOption('maxValue')"
|
||||
:min="getTypeOption('minValue')"
|
||||
:precision="getTypeOption('numberPrecision')"
|
||||
:disabled="isReadOnly"
|
||||
:class="{ 'ph-no-capture': shouldRedactValue }"
|
||||
:title="displayTitle"
|
||||
|
||||
@@ -1,40 +1,23 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useFocusPanelStore } from '@/stores/focusPanel.store';
|
||||
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', () => {
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('mutates nodeValues as expected', () => {
|
||||
const nodeSettingsParameters = useNodeSettingsParameters();
|
||||
|
||||
@@ -66,4 +49,76 @@ describe('useNodeSettingsParameters', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import type { IUpdateInformation } from '@/Interface';
|
||||
import get from 'lodash/get';
|
||||
import set from 'lodash/set';
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
type INode,
|
||||
type INodeParameters,
|
||||
type INodeProperties,
|
||||
type NodeParameterValue,
|
||||
NodeHelpers,
|
||||
deepCopy,
|
||||
} from 'n8n-workflow';
|
||||
import { useTelemetry } from './useTelemetry';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeHelpers } from './useNodeHelpers';
|
||||
import { useCanvasOperations } from './useCanvasOperations';
|
||||
import { useExternalHooks } from './useExternalHooks';
|
||||
import { ref } from 'vue';
|
||||
import type { INodeUi, IUpdateInformation } from '@/Interface';
|
||||
import { updateDynamicConnections, updateParameterByPath } from '@/utils/nodeSettingsUtils';
|
||||
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() {
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
@@ -43,7 +48,7 @@ export function useNodeSettingsParameters() {
|
||||
let lastNamePart: string | undefined = nameParts.pop();
|
||||
|
||||
let isArray = false;
|
||||
if (lastNamePart !== undefined && lastNamePart.includes('[')) {
|
||||
if (lastNamePart?.includes('[')) {
|
||||
// It includes an index so we have to extract it
|
||||
const lastNameParts = lastNamePart.match(/(.*)\[(\d+)\]$/);
|
||||
if (lastNameParts) {
|
||||
@@ -59,8 +64,7 @@ export function useNodeSettingsParameters() {
|
||||
if (value === null) {
|
||||
// Property should be deleted
|
||||
if (lastNamePart) {
|
||||
const { [lastNamePart]: removedNodeValue, ...remainingNodeValues } = nodeValues.value;
|
||||
nodeValues.value = remainingNodeValues;
|
||||
nodeValues.value = omitKey(nodeValues.value, lastNamePart);
|
||||
}
|
||||
} else {
|
||||
// Value should be set
|
||||
@@ -78,8 +82,7 @@ export function useNodeSettingsParameters() {
|
||||
| INodeParameters[];
|
||||
|
||||
if (lastNamePart && !Array.isArray(tempValue)) {
|
||||
const { [lastNamePart]: removedNodeValue, ...remainingNodeValues } = tempValue;
|
||||
tempValue = remainingNodeValues;
|
||||
tempValue = omitKey(tempValue, lastNamePart);
|
||||
}
|
||||
|
||||
if (isArray && Array.isArray(tempValue) && tempValue.length === 0) {
|
||||
@@ -88,9 +91,7 @@ export function useNodeSettingsParameters() {
|
||||
lastNamePart = nameParts.pop();
|
||||
tempValue = get(nodeValues.value, nameParts.join('.')) as INodeParameters;
|
||||
if (lastNamePart) {
|
||||
const { [lastNamePart]: removedArrayNodeValue, ...remainingArrayNodeValues } =
|
||||
tempValue;
|
||||
tempValue = remainingArrayNodeValues;
|
||||
tempValue = omitKey(tempValue, lastNamePart);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -114,12 +115,6 @@ export function useNodeSettingsParameters() {
|
||||
nodeValues.value = { ...nodeValues.value };
|
||||
}
|
||||
|
||||
function nameIsParameter(
|
||||
parameterData: IUpdateInformation,
|
||||
): parameterData is IUpdateInformation & { name: `parameters.${string}` } {
|
||||
return parameterData.name.startsWith('parameters.');
|
||||
}
|
||||
|
||||
function updateNodeParameter(
|
||||
parameterData: IUpdateInformation & { name: `parameters.${string}` },
|
||||
newValue: NodeParameterValue,
|
||||
@@ -221,11 +216,36 @@ export function useNodeSettingsParameters() {
|
||||
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 {
|
||||
nodeValues,
|
||||
setValue,
|
||||
updateParameterByPath,
|
||||
updateNodeParameter,
|
||||
nameIsParameter,
|
||||
handleFocus,
|
||||
shouldSkipParamValidation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,8 +147,12 @@ export const completeExpressionSyntax = <T>(value: T, isSpecializedEditor = fals
|
||||
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;
|
||||
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
!value.startsWith('=') &&
|
||||
|
||||
@@ -5,8 +5,15 @@ import type {
|
||||
NodeParameterValueType,
|
||||
IDataObject,
|
||||
INodeTypeDescription,
|
||||
INodePropertyOptions,
|
||||
} from 'n8n-workflow';
|
||||
import { updateDynamicConnections, updateParameterByPath } from './nodeSettingsUtils';
|
||||
import {
|
||||
updateDynamicConnections,
|
||||
updateParameterByPath,
|
||||
nameIsParameter,
|
||||
formatAsExpression,
|
||||
parseFromExpression,
|
||||
} from './nodeSettingsUtils';
|
||||
import { SWITCH_NODE_TYPE } from '@/constants';
|
||||
import type { INodeUi, IUpdateInformation } from '@/Interface';
|
||||
|
||||
@@ -14,6 +21,7 @@ describe('updateDynamicConnections', () => {
|
||||
afterAll(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should remove extra outputs when the number of outputs decreases', () => {
|
||||
const node = mock<INodeUi>({
|
||||
name: 'TestNode',
|
||||
@@ -282,3 +290,109 @@ describe('updateParameterByPath', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,15 @@ import {
|
||||
type INode,
|
||||
type INodeParameters,
|
||||
type NodeParameterValue,
|
||||
type INodeProperties,
|
||||
type INodePropertyOptions,
|
||||
type INodePropertyCollection,
|
||||
type NodePropertyTypes,
|
||||
isINodePropertyCollectionList,
|
||||
isINodePropertiesList,
|
||||
isINodePropertyOptionsList,
|
||||
displayParameter,
|
||||
isResourceLocatorValue,
|
||||
} from 'n8n-workflow';
|
||||
import type { INodeUi, IUpdateInformation } from '@/Interface';
|
||||
import { SWITCH_NODE_TYPE } from '@/constants';
|
||||
@@ -21,6 +26,7 @@ import set from 'lodash/set';
|
||||
import unset from 'lodash/unset';
|
||||
|
||||
import { captureException } from '@sentry/vue';
|
||||
import { isPresent } from './typesUtils';
|
||||
|
||||
export function updateDynamicConnections(
|
||||
node: INodeUi,
|
||||
@@ -235,3 +241,99 @@ export function updateParameterByPath(
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) => [
|
||||
value,
|
||||
@@ -154,4 +160,37 @@ describe('objectUtils', () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -170,3 +170,39 @@ export const tryToParseNumber = (value: string): number | string => {
|
||||
export function isPresent<T>(arg: T): arg is Exclude<T, null | 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'
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user