n8n/packages/editor-ui/src/components/ParameterInput.vue
Mutasem Aldmour 8a8d4e8bb3
fix: Load remote resources even if expressions in non requried parameters resolve (#6987)
Github issue / Community forum post (link here to close automatically):
2023-08-31 16:40:20 +02:00

1385 lines
38 KiB
Vue

<template>
<div @keydown.stop :class="parameterInputClasses">
<expression-edit
:dialogVisible="expressionEditDialogVisible"
:modelValue="
isResourceLocatorParameter && typeof modelValue !== 'string'
? modelValue
? modelValue.value
: ''
: modelValue
"
:parameter="parameter"
:path="path"
:eventSource="eventSource || 'ndv'"
:isReadOnly="isReadOnly"
:redactValues="shouldRedactValue"
@closeDialog="closeExpressionEditDialog"
@update:modelValue="expressionUpdated"
></expression-edit>
<div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle">
<resource-locator
v-if="isResourceLocatorParameter"
ref="resourceLocator"
:parameter="parameter"
:modelValue="modelValue"
:dependentParametersValues="dependentParametersValues"
:displayTitle="displayTitle"
:expressionDisplayValue="expressionDisplayValue"
:expressionComputedValue="expressionEvaluated"
:isValueExpression="isValueExpression"
:isReadOnly="isReadOnly"
:parameterIssues="getIssues"
:droppable="droppable"
:node="node"
:path="path"
:event-bus="eventBus"
@update:modelValue="valueChanged"
@modalOpenerClick="openExpressionEditorModal"
@focus="setFocus"
@blur="onBlur"
@drop="onResourceLocatorDrop"
/>
<ExpressionParameterInput
v-else-if="isValueExpression || forceShowExpression"
:modelValue="expressionDisplayValue"
:title="displayTitle"
:isReadOnly="isReadOnly"
:path="path"
:additional-expression-data="additionalExpressionData"
:class="{ 'ph-no-capture': shouldRedactValue }"
@update:modelValue="expressionUpdated"
@modalOpenerClick="openExpressionEditorModal"
@focus="setFocus"
@blur="onBlur"
ref="inputField"
/>
<div
v-else-if="
['json', 'string'].includes(parameter.type) ||
remoteParameterOptionsLoadingIssues !== null
"
>
<el-dialog
v-if="codeEditDialogVisible"
:modelValue="true"
append-to-body
:close-on-click-modal="false"
width="80%"
:title="`${i18n.baseText('codeEdit.edit')} ${$locale
.nodeText()
.inputLabelDisplayName(parameter, path)}`"
:before-close="closeCodeEditDialog"
>
<div class="ignore-key-press">
<code-node-editor
:modelValue="modelValue"
:defaultValue="parameter.default"
:language="editorLanguage"
:isReadOnly="isReadOnly"
@update:modelValue="expressionUpdated"
/>
</div>
</el-dialog>
<text-edit
:dialogVisible="textEditDialogVisible"
:modelValue="modelValue"
:parameter="parameter"
:path="path"
:isReadOnly="isReadOnly"
@closeDialog="closeTextEditDialog"
@update:modelValue="expressionUpdated"
></text-edit>
<code-node-editor
v-if="editorType === 'codeNodeEditor' && isCodeNode(node)"
:mode="node.parameters.mode"
:modelValue="modelValue"
:defaultValue="parameter.default"
:language="editorLanguage"
:isReadOnly="isReadOnly"
:aiButtonEnabled="settingsStore.isCloudDeployment"
@update:modelValue="valueChangedDebounced"
/>
<html-editor
v-else-if="editorType === 'htmlEditor'"
:modelValue="modelValue"
:isReadOnly="isReadOnly"
:rows="getArgument('rows')"
:disableExpressionColoring="!isHtmlNode(node)"
:disableExpressionCompletions="!isHtmlNode(node)"
@update:modelValue="valueChangedDebounced"
/>
<sql-editor
v-else-if="editorType === 'sqlEditor'"
:modelValue="modelValue"
:dialect="getArgument('sqlDialect')"
:isReadOnly="isReadOnly"
@update:modelValue="valueChangedDebounced"
/>
<div v-else-if="editorType" class="readonly-code clickable" @click="displayEditDialog()">
<code-node-editor
v-if="!codeEditDialogVisible"
:modelValue="modelValue"
:language="editorLanguage"
:isReadOnly="true"
/>
</div>
<n8n-input
v-else
v-model="tempValue"
ref="inputField"
:class="{ 'input-with-opener': true, 'ph-no-capture': shouldRedactValue }"
:size="inputSize"
:type="getStringInputType"
:rows="getArgument('rows')"
:disabled="isReadOnly"
@update:modelValue="valueChanged($event) && onUpdateTextInput($event)"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
:placeholder="getPlaceholder()"
>
<template #suffix>
<n8n-icon
v-if="!isReadOnly && !isSecretParameter"
icon="external-link-alt"
size="xsmall"
class="edit-window-button textarea-modal-opener"
:class="{
focused: isFocused,
invalid: !isFocused && getIssues.length > 0 && !isValueExpression,
}"
:title="i18n.baseText('parameterInput.openEditWindow')"
@click="displayEditDialog()"
@focus="setFocus"
/>
</template>
</n8n-input>
</div>
<div v-else-if="parameter.type === 'color'" ref="inputField" class="color-input">
<el-color-picker
size="small"
class="color-picker"
:modelValue="displayValue"
:disabled="isReadOnly"
@focus="setFocus"
@blur="onBlur"
@update:modelValue="valueChanged"
:title="displayTitle"
:show-alpha="getArgument('showAlpha')"
/>
<n8n-input
v-model="tempValue"
:size="inputSize"
type="text"
:disabled="isReadOnly"
@update:modelValue="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
/>
</div>
<el-date-picker
v-else-if="parameter.type === 'dateTime'"
v-model="tempValue"
ref="inputField"
type="datetime"
:size="inputSize"
:modelValue="displayValue"
:title="displayTitle"
:disabled="isReadOnly"
:placeholder="
parameter.placeholder
? getPlaceholder()
: i18n.baseText('parameterInput.selectDateAndTime')
"
:picker-options="dateTimePickerOptions"
:class="{ 'ph-no-capture': shouldRedactValue }"
@update:modelValue="valueChanged"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
/>
<n8n-input-number
v-else-if="parameter.type === 'number'"
ref="inputField"
:size="inputSize"
:modelValue="displayValue"
:controls="false"
:max="getArgument('maxValue')"
:min="getArgument('minValue')"
:precision="getArgument('numberPrecision')"
:disabled="isReadOnly"
:class="{ 'ph-no-capture': shouldRedactValue }"
@update:modelValue="onUpdateTextInput"
@focus="setFocus"
@blur="onBlur"
@keydown.stop
:title="displayTitle"
:placeholder="parameter.placeholder"
/>
<credentials-select
v-else-if="parameter.type === 'credentialsSelect' || parameter.name === 'genericAuthType'"
ref="inputField"
:parameter="parameter"
:node="node"
:activeCredentialType="activeCredentialType"
:inputSize="inputSize"
:displayValue="displayValue"
:isReadOnly="isReadOnly"
:displayTitle="displayTitle"
@credentialSelected="credentialSelected"
@update:modelValue="valueChanged"
@setFocus="setFocus"
@onBlur="onBlur"
>
<template #issues-and-options>
<parameter-issues :issues="getIssues" />
</template>
</credentials-select>
<n8n-select
v-else-if="parameter.type === 'options'"
ref="inputField"
:size="inputSize"
filterable
:modelValue="displayValue"
:placeholder="
parameter.placeholder ? getPlaceholder() : i18n.baseText('parameterInput.select')
"
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
@update:modelValue="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
>
<n8n-option
v-for="option in parameterOptions"
:value="option.value"
:key="option.value"
:label="getOptionsOptionDisplayName(option)"
>
<div class="list-option">
<div
class="option-headline"
:class="{ 'remote-parameter-option': isRemoteParameterOption(option) }"
>
{{ getOptionsOptionDisplayName(option) }}
</div>
<div
v-if="option.description"
class="option-description"
v-html="getOptionsOptionDescription(option)"
></div>
</div>
</n8n-option>
</n8n-select>
<n8n-select
v-else-if="parameter.type === 'multiOptions'"
ref="inputField"
:size="inputSize"
filterable
multiple
:modelValue="displayValue"
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
:placeholder="i18n.baseText('parameterInput.select')"
@update:modelValue="valueChanged"
@keydown.stop
@focus="setFocus"
@blur="onBlur"
>
<n8n-option
v-for="option in parameterOptions"
:value="option.value"
:key="option.value"
:label="getOptionsOptionDisplayName(option)"
>
<div class="list-option">
<div class="option-headline">{{ getOptionsOptionDisplayName(option) }}</div>
<div
v-if="option.description"
class="option-description"
v-html="getOptionsOptionDescription(option)"
></div>
</div>
</n8n-option>
</n8n-select>
<!-- temporary state of booleans while data is mapped -->
<n8n-input
v-else-if="parameter.type === 'boolean' && droppable"
:size="inputSize"
:modelValue="JSON.stringify(displayValue)"
:disabled="isReadOnly"
:title="displayTitle"
/>
<el-switch
v-else-if="parameter.type === 'boolean'"
:class="{ 'switch-input': true, 'ph-no-capture': shouldRedactValue }"
ref="inputField"
active-color="#13ce66"
:modelValue="displayValue"
:disabled="isReadOnly"
@update:modelValue="valueChanged"
/>
</div>
<parameter-issues
v-if="parameter.type !== 'credentialsSelect' && !isResourceLocatorParameter"
:issues="getIssues"
/>
</div>
</template>
<script lang="ts">
/* eslint-disable prefer-spread */
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import { get } from 'lodash-es';
import type { INodeUi, INodeUpdatePropertiesInformation } from '@/Interface';
import type {
ILoadOptions,
INodeParameters,
INodePropertyOptions,
Workflow,
INodeProperties,
INodePropertyCollection,
NodeParameterValueType,
IParameterLabel,
EditorType,
CodeNodeEditorLanguage,
IDataObject,
} from 'n8n-workflow';
import { NodeHelpers, CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
import CredentialsSelect from '@/components/CredentialsSelect.vue';
import ExpressionEdit from '@/components/ExpressionEdit.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import ResourceLocator from '@/components/ResourceLocator/ResourceLocator.vue';
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
import TextEdit from '@/components/TextEdit.vue';
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
import HtmlEditor from '@/components/HtmlEditor/HtmlEditor.vue';
import SqlEditor from '@/components/SqlEditor/SqlEditor.vue';
import { externalHooks } from '@/mixins/externalHooks';
import { nodeHelpers } from '@/mixins/nodeHelpers';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { hasExpressionMapping, isValueExpression, isResourceLocatorValue } from '@/utils';
import { CODE_NODE_TYPE, CUSTOM_API_CALL_KEY, HTML_NODE_TYPE } from '@/constants';
import type { PropType } from 'vue';
import { debounceHelper } from '@/mixins/debounce';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useSettingsStore } from '@/stores/settings.store';
import { htmlEditorEventBus } from '@/event-bus';
import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
import { useI18n } from '@/composables';
import type { N8nInput } from 'n8n-design-system';
export default defineComponent({
name: 'parameter-input',
mixins: [externalHooks, nodeHelpers, workflowHelpers, debounceHelper],
components: {
CodeNodeEditor,
HtmlEditor,
SqlEditor,
ExpressionEdit,
ExpressionParameterInput,
CredentialsSelect,
ParameterIssues,
ResourceLocator,
TextEdit,
},
props: {
additionalExpressionData: {
type: Object as PropType<IDataObject>,
default: () => ({}),
},
isReadOnly: {
type: Boolean,
},
parameter: {
type: Object as PropType<INodeProperties>,
},
path: {
type: String,
},
modelValue: {
type: [String, Number, Boolean, Array, Object] as PropType<NodeParameterValueType>,
},
hideLabel: {
type: Boolean,
},
droppable: {
type: Boolean,
},
activeDrop: {
type: Boolean,
},
forceShowExpression: {
type: Boolean,
},
hint: {
type: String as PropType<string | undefined>,
},
inputSize: {
type: String,
},
hideIssues: {
type: Boolean,
},
documentationUrl: {
type: String as PropType<string | undefined>,
},
errorHighlight: {
type: Boolean,
},
isForCredential: {
type: Boolean,
},
eventSource: {
type: String,
},
expressionEvaluated: {
type: String as PropType<string | undefined>,
},
label: {
type: Object as PropType<IParameterLabel>,
default: () => ({
size: 'small',
}),
},
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
},
setup() {
const i18n = useI18n();
return {
i18n,
};
},
data() {
return {
codeEditDialogVisible: false,
nodeName: '',
expressionAddOperation: 'set' as 'add' | 'set',
expressionEditDialogVisible: false,
remoteParameterOptions: [] as INodePropertyOptions[],
remoteParameterOptionsLoading: false,
remoteParameterOptionsLoadingIssues: null as string | null,
textEditDialogVisible: false,
editDialogClosing: false,
tempValue: '', // el-date-picker and el-input does not seem to work without v-model so add one
CUSTOM_API_CALL_KEY,
activeCredentialType: '',
dateTimePickerOptions: {
shortcuts: [
{
text: 'Today', // TODO
onClick(picker: any) {
picker.$emit('pick', new Date());
},
},
{
text: 'Yesterday', // TODO
onClick(picker: any) {
const date = new Date();
date.setTime(date.getTime() - 3600 * 1000 * 24);
picker.$emit('pick', date);
},
},
{
text: 'A week ago', // TODO
onClick(picker: any) {
const date = new Date();
date.setTime(date.getTime() - 3600 * 1000 * 24 * 7);
picker.$emit('pick', date);
},
},
],
},
isFocused: false,
};
},
watch: {
async dependentParametersValues() {
// Reload the remote parameters whenever a parameter
// on which the current field depends on changes
await this.loadRemoteParameterOptions();
},
modelValue() {
if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true) {
// Do not set for color with alpha else wrong value gets displayed in field
return;
}
this.tempValue = this.displayValue as string;
},
},
computed: {
...mapStores(
useCredentialsStore,
useNodeTypesStore,
useNDVStore,
useWorkflowsStore,
useSettingsStore,
),
expressionDisplayValue(): string {
if (this.forceShowExpression) {
return '';
}
const value = isResourceLocatorValue(this.modelValue)
? this.modelValue.value
: this.modelValue;
if (typeof value === 'string' && value.startsWith('=')) {
return value.slice(1);
}
return `${this.displayValue ?? ''}`;
},
isValueExpression(): boolean {
return isValueExpression(this.parameter, this.modelValue);
},
codeAutocomplete(): string | undefined {
return this.getArgument('codeAutocomplete') as string | undefined;
},
dependentParametersValues(): string | null {
const loadOptionsDependsOn = this.getArgument('loadOptionsDependsOn') as string[] | undefined;
if (loadOptionsDependsOn === undefined) {
return null;
}
// Get the resolved parameter values of the current node
const currentNodeParameters = this.ndvStore.activeNode?.parameters;
try {
const resolvedNodeParameters = this.resolveParameter(currentNodeParameters);
const returnValues: string[] = [];
for (const parameterPath of loadOptionsDependsOn) {
returnValues.push(get(resolvedNodeParameters, parameterPath) as string);
}
return returnValues.join('|');
} catch (error) {
return null;
}
},
node(): INodeUi {
return this.ndvStore.activeNode!;
},
displayTitle(): string {
const interpolation = { interpolate: { shortPath: this.shortPath } };
if (this.getIssues.length && this.isValueExpression) {
return this.i18n.baseText('parameterInput.parameterHasIssuesAndExpression', interpolation);
} else if (this.getIssues.length && !this.isValueExpression) {
return this.i18n.baseText('parameterInput.parameterHasIssues', interpolation);
} else if (!this.getIssues.length && this.isValueExpression) {
return this.i18n.baseText('parameterInput.parameterHasExpression', interpolation);
}
return this.i18n.baseText('parameterInput.parameter', interpolation);
},
displayValue(): string | number | boolean | null {
if (this.remoteParameterOptionsLoading === true) {
// If it is loading options from server display
// to user that the data is loading. If not it would
// display the user the key instead of the value it
// represents
return this.i18n.baseText('parameterInput.loadingOptions');
}
// if the value is marked as empty return empty string, to prevent displaying the asterisks
if (this.modelValue === CREDENTIAL_EMPTY_VALUE) {
return '';
}
let returnValue;
if (this.isValueExpression === false) {
returnValue = this.isResourceLocatorParameter
? isResourceLocatorValue(this.modelValue)
? this.modelValue.value
: ''
: this.modelValue;
} else {
returnValue = this.expressionEvaluated;
}
if (this.parameter.type === 'credentialsSelect' && typeof this.modelValue === 'string') {
const credType = this.credentialsStore.getCredentialTypeByName(this.modelValue);
if (credType) {
returnValue = credType.displayName;
}
}
if (
Array.isArray(returnValue) &&
this.parameter.type === 'color' &&
this.getArgument('showAlpha') === true &&
returnValue.charAt(0) === '#'
) {
// Convert the value to rgba that el-color-picker can display it correctly
const bigint = parseInt(returnValue.slice(1), 16);
const h = [];
h.push((bigint >> 24) & 255);
h.push((bigint >> 16) & 255);
h.push((bigint >> 8) & 255);
h.push(((255 - bigint) & 255) / 255);
returnValue = 'rgba(' + h.join() + ')';
}
if (returnValue !== undefined && returnValue !== null && this.parameter.type === 'string') {
const rows = this.getArgument('rows');
if (rows === undefined || rows === 1) {
returnValue = returnValue.toString().replace(/\n/, '|');
}
}
return returnValue;
},
getStringInputType() {
if (this.getArgument('password') === true) {
return 'password';
}
const rows = this.getArgument('rows');
if (rows !== undefined && rows > 1) {
return 'textarea';
}
if (this.editorType === 'code') {
return 'textarea';
}
return 'text';
},
getIssues(): string[] {
if (this.hideIssues === true || this.node === null) {
return [];
}
const newPath = this.shortPath.split('.');
newPath.pop();
const issues = NodeHelpers.getParameterIssues(
this.parameter,
this.node.parameters,
newPath.join('.'),
this.node,
);
if (this.parameter.type === 'credentialsSelect' && this.displayValue === '') {
issues.parameters = issues.parameters || {};
const issue = this.i18n.baseText('parameterInput.selectACredentialTypeFromTheDropdown');
issues.parameters[this.parameter.name] = [issue];
} else if (
['options', 'multiOptions'].includes(this.parameter.type) &&
this.remoteParameterOptionsLoading === false &&
this.remoteParameterOptionsLoadingIssues === null &&
this.parameterOptions
) {
// Check if the value resolves to a valid option
// Currently it only displays an error in the node itself in
// case the value is not valid. The workflow can still be executed
// and the error is not displayed on the node in the workflow
const validOptions = this.parameterOptions.map(
(options) => (options as INodePropertyOptions).value,
);
const checkValues: string[] = [];
if (!this.skipCheck(this.displayValue)) {
if (Array.isArray(this.displayValue)) {
checkValues.push.apply(checkValues, this.displayValue);
} else {
checkValues.push(this.displayValue as string);
}
}
for (const checkValue of checkValues) {
if (checkValue === null || !validOptions.includes(checkValue)) {
if (issues.parameters === undefined) {
issues.parameters = {};
}
const issue = this.i18n.baseText('parameterInput.theValueIsNotSupported', {
interpolate: { checkValue },
});
issues.parameters[this.parameter.name] = [issue];
}
}
} else if (this.remoteParameterOptionsLoadingIssues !== null && !this.isValueExpression) {
if (issues.parameters === undefined) {
issues.parameters = {};
}
issues.parameters[this.parameter.name] = [
`There was a problem loading the parameter options from server: "${this.remoteParameterOptionsLoadingIssues}"`,
];
}
if (
issues !== undefined &&
issues.parameters !== undefined &&
issues.parameters[this.parameter.name] !== undefined
) {
return issues.parameters[this.parameter.name];
}
return [];
},
editorType(): EditorType {
return this.getArgument('editor') as EditorType;
},
editorLanguage(): CodeNodeEditorLanguage {
if (this.editorType === 'json' || this.parameter.type === 'json') return 'json';
return (this.getArgument('editorLanguage') as CodeNodeEditorLanguage) ?? 'javaScript';
},
parameterOptions():
| Array<INodePropertyOptions | INodeProperties | INodePropertyCollection>
| undefined {
if (this.hasRemoteMethod === false) {
// Options are already given
return this.parameter.options;
}
// Options get loaded from server
return this.remoteParameterOptions;
},
parameterInputClasses() {
const classes: { [c: string]: boolean } = {
droppable: this.droppable,
activeDrop: this.activeDrop,
};
const rows = this.getArgument('rows');
const isTextarea = this.parameter.type === 'string' && rows !== undefined;
const isSwitch = this.parameter.type === 'boolean' && !this.isValueExpression;
if (!isTextarea && !isSwitch) {
classes['parameter-value-container'] = true;
}
if (
!this.droppable &&
!this.activeDrop &&
(this.getIssues.length > 0 || this.errorHighlight) &&
!this.isValueExpression
) {
classes['has-issues'] = true;
}
return classes;
},
parameterInputWrapperStyle() {
let deductWidth = 0;
const styles = {
width: '100%',
};
if (this.parameter.type === 'credentialsSelect' || this.isResourceLocatorParameter) {
return styles;
}
if (this.getIssues.length) {
deductWidth += 20;
}
if (deductWidth !== 0) {
styles.width = `calc(100% - ${deductWidth}px)`;
}
return styles;
},
hasRemoteMethod(): boolean {
return !!this.getArgument('loadOptionsMethod') || !!this.getArgument('loadOptions');
},
shortPath(): string {
const shortPath = this.path.split('.');
shortPath.shift();
return shortPath.join('.');
},
workflow(): Workflow {
return this.getCurrentWorkflow();
},
isResourceLocatorParameter(): boolean {
return this.parameter.type === 'resourceLocator';
},
isSecretParameter(): boolean {
return this.getArgument('password') === true;
},
remoteParameterOptionsKeys(): string[] {
return (this.remoteParameterOptions || []).map((o) => o.name);
},
shouldRedactValue(): boolean {
return this.getStringInputType === 'password' || this.isForCredential;
},
},
methods: {
isRemoteParameterOption(option: INodePropertyOptions) {
return this.remoteParameterOptionsKeys.includes(option.name);
},
credentialSelected(updateInformation: INodeUpdatePropertiesInformation) {
// Update the values on the node
this.workflowsStore.updateNodeProperties(updateInformation);
const node = this.workflowsStore.getNodeByName(updateInformation.name);
if (node) {
// Update the issues
this.updateNodeCredentialIssues(node);
}
void this.$externalHooks().run('nodeSettings.credentialSelected', { updateInformation });
},
/**
* Check whether a param value must be skipped when collecting node param issues for validation.
*/
skipCheck(value: string | number | boolean | null) {
return typeof value === 'string' && value.includes(CUSTOM_API_CALL_KEY);
},
getPlaceholder(): string {
return this.isForCredential
? this.i18n.credText().placeholder(this.parameter)
: this.i18n.nodeText().placeholder(this.parameter, this.path);
},
getOptionsOptionDisplayName(option: INodePropertyOptions): string {
return this.isForCredential
? this.i18n.credText().optionsOptionDisplayName(this.parameter, option)
: this.i18n.nodeText().optionsOptionDisplayName(this.parameter, option, this.path);
},
getOptionsOptionDescription(option: INodePropertyOptions): string {
return this.isForCredential
? this.i18n.credText().optionsOptionDescription(this.parameter, option)
: this.i18n.nodeText().optionsOptionDescription(this.parameter, option, this.path);
},
async loadRemoteParameterOptions() {
if (
this.node === null ||
this.hasRemoteMethod === false ||
this.remoteParameterOptionsLoading ||
!this.parameter
) {
return;
}
this.remoteParameterOptionsLoadingIssues = null;
this.remoteParameterOptionsLoading = true;
this.remoteParameterOptions.length = 0;
// Get the resolved parameter values of the current node
try {
const currentNodeParameters = (this.ndvStore.activeNode as INodeUi).parameters;
const resolvedNodeParameters = this.resolveRequiredParameters(
this.parameter,
currentNodeParameters,
) as INodeParameters;
const loadOptionsMethod = this.getArgument('loadOptionsMethod') as string | undefined;
const loadOptions = this.getArgument('loadOptions') as ILoadOptions | undefined;
const options = await this.nodeTypesStore.getNodeParameterOptions({
nodeTypeAndVersion: {
name: this.node.type,
version: this.node.typeVersion,
},
path: this.path,
methodName: loadOptionsMethod,
loadOptions,
currentNodeParameters: resolvedNodeParameters,
credentials: this.node.credentials,
});
this.remoteParameterOptions.push.apply(this.remoteParameterOptions, options);
} catch (error) {
this.remoteParameterOptionsLoadingIssues = error.message;
}
this.remoteParameterOptionsLoading = false;
},
closeCodeEditDialog() {
this.codeEditDialogVisible = false;
this.editDialogClosing = true;
void this.$nextTick(() => {
this.editDialogClosing = false;
});
},
closeExpressionEditDialog() {
this.expressionEditDialogVisible = false;
},
trackExpressionEditOpen() {
if (!this.node) {
return;
}
if ((this.node.type as string).startsWith('n8n-nodes-base')) {
this.$telemetry.track('User opened Expression Editor', {
node_type: this.node.type,
parameter_name: this.parameter.displayName,
parameter_field_type: this.parameter.type,
new_expression: !this.isValueExpression,
workflow_id: this.workflowsStore.workflowId,
session_id: this.ndvStore.sessionId,
source: this.eventSource || 'ndv',
});
}
},
closeTextEditDialog() {
this.textEditDialogVisible = false;
this.editDialogClosing = true;
void this.$nextTick(() => {
this.$refs.inputField?.blur?.();
this.editDialogClosing = false;
});
},
displayEditDialog() {
if (this.editDialogClosing) {
return;
}
if (this.editorType) {
this.codeEditDialogVisible = true;
} else {
this.textEditDialogVisible = true;
}
},
getArgument(argumentName: string): string | number | boolean | undefined {
return this.parameter.typeOptions?.[argumentName];
},
expressionUpdated(value: string) {
const val: NodeParameterValueType = this.isResourceLocatorParameter
? { __rl: true, value, mode: this.modelValue.mode }
: value;
this.valueChanged(val);
},
openExpressionEditorModal() {
if (!this.isValueExpression) return;
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
},
onBlur() {
this.$emit('blur');
this.isFocused = false;
},
onResourceLocatorDrop(data: string) {
this.$emit('drop', data);
},
async setFocus(event: MouseEvent) {
if (['json'].includes(this.parameter.type) && this.getArgument('alwaysOpenEditWindow')) {
this.displayEditDialog();
return;
}
if (this.node !== null) {
// 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
this.nodeName = this.node.name;
}
await this.$nextTick();
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
const inputRef = this.$refs.inputField as InstanceType<N8nInput> | undefined;
if (inputRef?.$el) {
if (inputRef.focusOnInput) {
inputRef.focusOnInput();
} else if (inputRef.focus) {
inputRef.focus();
}
this.isFocused = true;
}
this.$emit('focus');
},
isCodeNode(node: INodeUi): boolean {
return node.type === CODE_NODE_TYPE;
},
isHtmlNode(node: INodeUi): boolean {
return node.type === HTML_NODE_TYPE;
},
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 as string).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)
);
},
onTextInputChange(value: string) {
const parameterData = {
node: this.node !== null ? this.node.name : this.nodeName,
name: this.path,
value,
};
this.$emit('textInput', parameterData);
},
valueChangedDebounced(value: NodeParameterValueType | {} | Date) {
void this.callDebounced('valueChanged', { debounceTime: 100 }, value);
},
onUpdateTextInput(value: string) {
this.valueChanged(value);
this.onTextInputChange(value);
},
valueChanged(value: NodeParameterValueType | {} | Date) {
if (this.parameter.name === 'nodeCredentialType') {
this.activeCredentialType = value as string;
}
if (value instanceof Date) {
value = value.toISOString();
}
if (
this.parameter.type === 'color' &&
this.getArgument('showAlpha') === true &&
value !== null &&
value !== undefined &&
value.toString().charAt(0) !== '#'
) {
const newValue = this.rgbaToHex(value as string);
if (newValue !== null) {
this.tempValue = newValue;
value = newValue;
}
}
const parameterData = {
node: this.node !== null ? this.node.name : this.nodeName,
name: this.path,
value,
};
this.$emit('update', parameterData);
if (this.parameter.name === 'operation' || this.parameter.name === 'mode') {
this.$telemetry.track('User set node operation or mode', {
workflow_id: this.workflowsStore.workflowId,
node_type: this.node && this.node.type,
resource: this.node && this.node.parameters.resource,
is_custom: value === CUSTOM_API_CALL_KEY,
session_id: this.ndvStore.sessionId,
parameter: this.parameter.name,
});
}
},
async optionSelected(command: string) {
const prevValue = this.modelValue;
if (command === 'resetValue') {
this.valueChanged(this.parameter.default);
} else if (command === 'addExpression') {
if (this.isResourceLocatorParameter) {
if (isResourceLocatorValue(this.modelValue)) {
this.valueChanged({
__rl: true,
value: `=${this.modelValue.value}`,
mode: this.modelValue.mode,
});
} else {
this.valueChanged({ __rl: true, value: `=${this.modelValue}`, mode: '' });
}
} else if (
this.parameter.type === 'number' &&
(!this.modelValue || this.modelValue === '[Object: null]')
) {
this.valueChanged('={{ 0 }}');
} else if (this.parameter.type === 'number' || this.parameter.type === 'boolean') {
this.valueChanged(`={{ ${this.modelValue} }}`);
} else {
this.valueChanged(`=${this.modelValue}`);
}
await this.setFocus();
} else if (command === 'removeExpression') {
let value: NodeParameterValueType = this.expressionEvaluated;
this.isFocused = false;
if (this.parameter.type === 'multiOptions' && typeof value === 'string') {
value = (value || '')
.split(',')
.filter((value) =>
(this.parameterOptions || []).find(
(option) => (option as INodePropertyOptions).value === value,
),
);
}
if (this.isResourceLocatorParameter && isResourceLocatorValue(this.modelValue)) {
this.valueChanged({ __rl: true, value, mode: this.modelValue.mode });
} else {
let newValue = typeof value !== 'undefined' ? value : null;
if (this.parameter.type === 'string') {
// Strip the '=' from the beginning
newValue = this.modelValue ? this.modelValue.toString().substring(1) : null;
}
this.valueChanged(newValue);
}
} else if (command === 'refreshOptions') {
if (this.isResourceLocatorParameter) {
this.eventBus.emit('refreshList');
}
void this.loadRemoteParameterOptions();
} else if (command === 'formatHtml') {
htmlEditorEventBus.emit('format-html');
}
if (this.node && (command === 'addExpression' || command === 'removeExpression')) {
const telemetryPayload = {
node_type: this.node.type,
parameter: this.path,
old_mode: command === 'addExpression' ? 'fixed' : 'expression',
new_mode: command === 'removeExpression' ? 'fixed' : 'expression',
was_parameter_empty: prevValue === '' || prevValue === undefined,
had_mapping: hasExpressionMapping(prevValue),
had_parameter: typeof prevValue === 'string' && prevValue.includes('$parameter'),
};
this.$telemetry.track('User switched parameter mode', telemetryPayload);
void this.$externalHooks().run('parameterInput.modeSwitch', telemetryPayload);
}
},
},
async updated() {
await this.$nextTick();
const remoteParameterOptions = this.$el.querySelectorAll('.remote-parameter-option');
if (remoteParameterOptions.length > 0) {
void this.$externalHooks().run('parameterInput.updated', { remoteParameterOptions });
}
},
mounted() {
this.eventBus.on('optionSelected', this.optionSelected);
this.tempValue = this.displayValue as string;
if (this.node !== null) {
this.nodeName = this.node.name;
}
if (this.node && this.node.parameters.authentication === 'predefinedCredentialType') {
this.activeCredentialType = this.node.parameters.nodeCredentialType as string;
}
if (
this.parameter.type === 'color' &&
this.getArgument('showAlpha') === true &&
this.displayValue !== null &&
this.displayValue.toString().charAt(0) !== '#'
) {
const newValue = this.rgbaToHex(this.displayValue as string);
if (newValue !== null) {
this.tempValue = newValue;
}
}
if (this.hasRemoteMethod === true && this.node !== null) {
// Make sure to load the parameter options
// directly and whenever the credentials change
this.$watch(
() => this.node?.credentials,
() => {
void this.loadRemoteParameterOptions();
},
{ deep: true, immediate: true },
);
}
void this.$externalHooks().run('parameterInput.mount', {
parameter: this.parameter,
inputFieldRef: this.$refs['inputField'],
});
},
beforeUnmount() {
this.eventBus.off('optionSelected', this.optionSelected);
},
});
</script>
<style scoped lang="scss">
.readonly-code {
font-size: var(--font-size-xs);
}
.switch-input {
margin: var(--spacing-5xs) 0 var(--spacing-2xs) 0;
}
.parameter-value-container {
display: flex;
align-items: center;
}
.parameter-actions {
display: inline-flex;
align-items: center;
}
.parameter-input {
display: inline-block;
:deep(.color-input) {
display: flex;
.el-color-picker__trigger {
border: none;
}
}
}
</style>
<style lang="scss">
.ql-editor {
padding: 6px;
line-height: 26px;
background-color: #f0f0f0;
}
.droppable {
--input-border-color: var(--color-secondary);
--input-border-style: dashed;
textarea,
input,
.cm-editor {
border-width: 1.5px;
}
}
.activeDrop {
--input-border-color: var(--color-success);
--input-background-color: var(--color-foreground-xlight);
--input-border-style: solid;
textarea,
input {
cursor: grabbing !important;
border-width: 1px;
}
}
.has-issues {
--input-border-color: var(--color-danger);
}
.el-dropdown {
color: var(--color-text-light);
}
.list-option {
margin: 6px 0;
white-space: normal;
padding-right: 20px;
.option-headline {
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-regular);
overflow-wrap: break-word;
}
.option-description {
margin-top: 2px;
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-regular);
line-height: var(--font-line-height-xloose);
color: $custom-font-very-light;
}
}
.edit-window-button {
display: none;
}
.parameter-input:hover .edit-window-button {
display: inline;
}
.expand-input-icon-container {
display: flex;
height: 100%;
align-items: center;
}
.input-with-opener .el-input__suffix {
right: 0;
}
.textarea-modal-opener {
position: absolute;
right: 0;
bottom: 0;
background-color: white;
padding: 3px;
line-height: 9px;
border: var(--border-base);
border-top-left-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base);
cursor: pointer;
svg {
width: 9px !important;
height: 9px;
transform: rotate(270deg);
&:hover {
color: var(--color-primary);
}
}
}
.focused {
border-color: var(--color-secondary);
}
.invalid {
border-color: var(--color-danger);
}
</style>