diff --git a/packages/frontend/editor-ui/src/components/NodeSettings.vue b/packages/frontend/editor-ui/src/components/NodeSettings.vue index 8be4ddda41..fbfa8b3c17 100644 --- a/packages/frontend/editor-ui/src/components/NodeSettings.vue +++ b/packages/frontend/editor-ui/src/components/NodeSettings.vue @@ -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 { diff --git a/packages/frontend/editor-ui/src/components/ParameterInput.vue b/packages/frontend/editor-ui/src/components/ParameterInput.vue index 6aa38973fd..013259e8ff 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInput.vue +++ b/packages/frontend/editor-ui/src/components/ParameterInput.vue @@ -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(() => { + const short = props.path.split('.'); + short.shift(); + return short.join('.'); +}); + +function getTypeOption(optionName: string): T { + return getParameterTypeOption(props.parameter, optionName); +} + +const isModelValueExpression = computed(() => isValueExpression(props.parameter, props.modelValue)); + +const isResourceLocatorParameter = computed(() => { + return isResourceLocatorParameterType(props.parameter.type); +}); + +const isSecretParameter = computed(() => { + return getTypeOption('password') === true; +}); + +const hasRemoteMethod = computed(() => { + 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(() => { + return props.modelValue as string; +}); + +const modelValueResourceLocator = computed(() => { + return props.modelValue as INodeParameterResourceLocator; +}); + +const modelValueExpressionEdit = computed(() => { + return isResourceLocatorParameter.value && typeof props.modelValue !== 'string' + ? props.modelValue + ? ((props.modelValue as INodeParameterResourceLocator).value as string) + : '' + : (props.modelValue as string); +}); + +const editorRows = computed(() => getTypeOption('rows')); + +const editorType = computed(() => { + return getTypeOption('editor'); +}); +const editorIsReadOnly = computed(() => { + return getTypeOption('editorIsReadOnly') ?? false; +}); + +const editorLanguage = computed(() => { + if (editorType.value === 'json' || props.parameter.type === 'json') + return 'json' as CodeNodeEditorLanguage; + return getTypeOption('editorLanguage') ?? 'javaScript'; +}); + +const codeEditorMode = computed(() => { + 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(() => { - const loadOptionsDependsOn = getArgument('loadOptionsDependsOn'); + const loadOptionsDependsOn = getTypeOption('loadOptionsDependsOn'); if (loadOptionsDependsOn === undefined) { return null; @@ -293,32 +368,13 @@ const dependentParametersValues = computed(() => { } 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(() => { - 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(() => { 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(() => { 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(() => { } } } 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(() => { ); 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(() => { return []; }); +const displayTitle = computed(() => { + 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(() => { - return getArgument('editor'); -}); -const editorIsReadOnly = computed(() => { - return getArgument('editorIsReadOnly') ?? false; -}); - -const editorLanguage = computed(() => { - if (editorType.value === 'json' || props.parameter.type === 'json') - return 'json' as CodeNodeEditorLanguage; - return getArgument('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(() => { - return !!getArgument('loadOptionsMethod') || !!getArgument('loadOptions'); -}); - -const shortPath = computed(() => { - const short = props.path.split('.'); - short.shift(); - return short.join('.'); -}); - const parameterId = computed(() => { return `${node.value?.id ?? crypto.randomUUID()}${props.path}`; }); -const isResourceLocatorParameter = computed(() => { - return props.parameter.type === 'resourceLocator' || props.parameter.type === 'workflowSelector'; -}); - -const isSecretParameter = computed(() => { - return getArgument('password') === true; -}); - const remoteParameterOptionsKeys = computed(() => { return (remoteParameterOptions.value || []).map((o) => o.name); }); @@ -530,28 +556,6 @@ const shouldRedactValue = computed(() => { return getStringInputType.value === 'password' || props.isForCredential; }); -const modelValueString = computed(() => { - return props.modelValue as string; -}); - -const modelValueResourceLocator = computed(() => { - return props.modelValue as INodeParameterResourceLocator; -}); - -const modelValueExpressionEdit = computed(() => { - return isResourceLocatorParameter.value && typeof props.modelValue !== 'string' - ? props.modelValue - ? ((props.modelValue as INodeParameterResourceLocator).value as string) - : '' - : (props.modelValue as string); -}); - -const editorRows = computed(() => getArgument('rows')); - -const codeEditorMode = computed(() => { - 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('loadOptionsMethod'); - const loadOptions = getArgument('loadOptions'); + const loadOptionsMethod = getTypeOption('loadOptionsMethod'); + const loadOptions = getTypeOption('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(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 = { + 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 = { - 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, }); }); @@ -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 () => { { { :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" diff --git a/packages/frontend/editor-ui/src/composables/useNodeSettingsParameters.test.ts b/packages/frontend/editor-ui/src/composables/useNodeSettingsParameters.test.ts index f0e5beb589..3f43d8117b 100644 --- a/packages/frontend/editor-ui/src/composables/useNodeSettingsParameters.test.ts +++ b/packages/frontend/editor-ui/src/composables/useNodeSettingsParameters.test.ts @@ -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; + let focusPanelStore: MockedStore; + + 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(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/composables/useNodeSettingsParameters.ts b/packages/frontend/editor-ui/src/composables/useNodeSettingsParameters.ts index fd28fd8f19..d0a7c45cd3 100644 --- a/packages/frontend/editor-ui/src/composables/useNodeSettingsParameters.ts +++ b/packages/frontend/editor-ui/src/composables/useNodeSettingsParameters.ts @@ -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, }; } diff --git a/packages/frontend/editor-ui/src/utils/expressions.ts b/packages/frontend/editor-ui/src/utils/expressions.ts index 1b341f8d15..f2ef3689a7 100644 --- a/packages/frontend/editor-ui/src/utils/expressions.ts +++ b/packages/frontend/editor-ui/src/utils/expressions.ts @@ -147,8 +147,12 @@ export const completeExpressionSyntax = (value: T, isSpecializedEditor = fals return value; }; -export const shouldConvertToExpression = (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('=') && diff --git a/packages/frontend/editor-ui/src/utils/nodeSettingsUtils.test.ts b/packages/frontend/editor-ui/src/utils/nodeSettingsUtils.test.ts index c4bb90d53c..e726644108 100644 --- a/packages/frontend/editor-ui/src/utils/nodeSettingsUtils.test.ts +++ b/packages/frontend/editor-ui/src/utils/nodeSettingsUtils.test.ts @@ -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({ 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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/utils/nodeSettingsUtils.ts b/packages/frontend/editor-ui/src/utils/nodeSettingsUtils.ts index 29d857043e..352e18f4f8 100644 --- a/packages/frontend/editor-ui/src/utils/nodeSettingsUtils.ts +++ b/packages/frontend/editor-ui/src/utils/nodeSettingsUtils.ts @@ -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( + 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; +} diff --git a/packages/frontend/editor-ui/src/utils/objectUtils.test.ts b/packages/frontend/editor-ui/src/utils/objectUtils.test.ts index ae2b7cf877..498bdc7068 100644 --- a/packages/frontend/editor-ui/src/utils/objectUtils.test.ts +++ b/packages/frontend/editor-ui/src/utils/objectUtils.test.ts @@ -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 }); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/utils/objectUtils.ts b/packages/frontend/editor-ui/src/utils/objectUtils.ts index 1958dcb3ea..2591548a50 100644 --- a/packages/frontend/editor-ui/src/utils/objectUtils.ts +++ b/packages/frontend/editor-ui/src/utils/objectUtils.ts @@ -50,3 +50,10 @@ export const getObjectSizeInKB = (obj: unknown): number => { ); } }; + +export function omitKey, S extends string>( + obj: T, + key: S, +): Omit { + return Object.fromEntries(Object.entries(obj).filter(([k]) => k !== key)) as Omit; +} diff --git a/packages/frontend/editor-ui/src/utils/typesUtils.ts b/packages/frontend/editor-ui/src/utils/typesUtils.ts index c89d79ab09..5837ff42f5 100644 --- a/packages/frontend/editor-ui/src/utils/typesUtils.ts +++ b/packages/frontend/editor-ui/src/utils/typesUtils.ts @@ -170,3 +170,39 @@ export const tryToParseNumber = (value: string): number | string => { export function isPresent(arg: T): arg is Exclude { 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' + ); +}