Files
n8n-enterprise-unlocked/packages/frontend/editor-ui/src/components/ParameterInputList.vue
2025-06-13 17:45:30 +03:00

876 lines
24 KiB
Vue

<script setup lang="ts">
import type {
CalloutActionType,
INodeParameters,
INodeProperties,
NodeParameterValue,
NodeParameterValueType,
} from 'n8n-workflow';
import { ADD_FORM_NOTICE, deepCopy, NodeHelpers } from 'n8n-workflow';
import { computed, defineAsyncComponent, onErrorCaptured, ref, watch, type WatchSource } from 'vue';
import type { IUpdateInformation } from '@/Interface';
import AssignmentCollection from '@/components/AssignmentCollection/AssignmentCollection.vue';
import ButtonParameter from '@/components/ButtonParameter/ButtonParameter.vue';
import FilterConditions from '@/components/FilterConditions/FilterConditions.vue';
import ImportCurlParameter from '@/components/ImportCurlParameter.vue';
import MultipleParameter from '@/components/MultipleParameter.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import { useI18n } from '@n8n/i18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useMessage } from '@/composables/useMessage';
import {
FORM_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
KEEP_AUTH_IN_NDV_FOR_NODES,
MODAL_CONFIRM,
WAIT_NODE_TYPE,
} from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import {
getMainAuthField,
getNodeAuthFields,
isAuthRelatedParameter,
} from '@/utils/nodeTypesUtils';
import { captureException } from '@sentry/vue';
import { computedWithControl } from '@vueuse/core';
import get from 'lodash/get';
import set from 'lodash/set';
import {
N8nCallout,
N8nIcon,
N8nIconButton,
N8nInputLabel,
N8nLink,
N8nNotice,
N8nText,
} from '@n8n/design-system';
import { storeToRefs } from 'pinia';
import { useCalloutHelpers } from '@/composables/useCalloutHelpers';
const LazyFixedCollectionParameter = defineAsyncComponent(
async () => await import('./FixedCollectionParameter.vue'),
);
const LazyCollectionParameter = defineAsyncComponent(
async () => await import('./CollectionParameter.vue'),
);
// Parameter issues are displayed within the inputs themselves, but some parameters need to show them in the label UI
const showIssuesInLabelFor = ['fixedCollection'];
type Props = {
nodeValues: INodeParameters;
parameters: INodeProperties[];
path?: string;
hideDelete?: boolean;
indent?: boolean;
isReadOnly?: boolean;
hiddenIssuesInputs?: string[];
entryIndex?: number;
};
const props = withDefaults(defineProps<Props>(), { path: '', hiddenIssuesInputs: () => [] });
const emit = defineEmits<{
activate: [];
valueChanged: [value: IUpdateInformation];
parameterBlur: [value: string];
}>();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
const message = useMessage();
const nodeHelpers = useNodeHelpers();
const asyncLoadingError = ref(false);
const workflowHelpers = useWorkflowHelpers();
const i18n = useI18n();
const { dismissCallout, isCalloutDismissed, openRagStarterTemplate, isRagStarterCalloutVisible } =
useCalloutHelpers();
const { activeNode } = storeToRefs(ndvStore);
onErrorCaptured((e, component) => {
if (
!['LazyFixedCollectionParameter', 'LazyCollectionParameter'].includes(
component?.$options.name as string,
)
) {
return;
}
asyncLoadingError.value = true;
console.error(e);
captureException(e, {
tags: {
asyncLoadingError: true,
},
});
// Don't propagate the error further
return false;
});
const nodeType = computed(() => {
if (node.value) {
return nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion);
}
return null;
});
const filteredParameters = computedWithControl(
[() => props.parameters, () => props.nodeValues] as WatchSource[],
() => {
const parameters = props.parameters.filter((parameter: INodeProperties) =>
displayNodeParameter(parameter),
);
const activeNode = ndvStore.activeNode;
if (activeNode && activeNode.type === FORM_TRIGGER_NODE_TYPE) {
return updateFormTriggerParameters(parameters, activeNode.name);
}
if (activeNode && activeNode.type === FORM_NODE_TYPE) {
return updateFormParameters(parameters, activeNode.name);
}
if (
activeNode &&
activeNode.type === WAIT_NODE_TYPE &&
activeNode.parameters.resume === 'form'
) {
return updateWaitParameters(parameters, activeNode.name);
}
return parameters;
},
);
const filteredParameterNames = computed(() => {
return filteredParameters.value.map((parameter) => parameter.name);
});
const node = computed(() => ndvStore.activeNode);
const nodeAuthFields = computed(() => {
return getNodeAuthFields(nodeType.value);
});
const credentialsParameterIndex = computed(() => {
return filteredParameters.value.findIndex((parameter) => parameter.type === 'credentials');
});
const indexToShowSlotAt = computed(() => {
if (credentialsParameterIndex.value !== -1) {
return credentialsParameterIndex.value;
}
let index = 0;
// For nodes that use old credentials UI, keep credentials below authentication field in NDV
// otherwise credentials will use auth filed position since the auth field is moved to credentials modal
const fieldOffset = KEEP_AUTH_IN_NDV_FOR_NODES.includes(nodeType.value?.name || '') ? 1 : 0;
const credentialsDependencies = getCredentialsDependencies();
filteredParameters.value.forEach((prop, propIndex) => {
if (credentialsDependencies.has(prop.name)) {
index = propIndex + fieldOffset;
}
});
return Math.min(index, filteredParameters.value.length - 1);
});
const mainNodeAuthField = computed(() => {
return getMainAuthField(nodeType.value || null);
});
watch(filteredParameterNames, (newValue, oldValue) => {
if (newValue === undefined) {
return;
}
// After a parameter does not get displayed anymore make sure that its value gets removed
// Is only needed for the edge-case when a parameter gets displayed depending on another field
// which contains an expression.
for (const parameter of oldValue) {
if (!newValue.includes(parameter)) {
const parameterData = {
name: `${props.path}.${parameter}`,
node: ndvStore.activeNode?.name || '',
value: undefined,
};
emit('valueChanged', parameterData);
}
}
});
function updateFormTriggerParameters(parameters: INodeProperties[], triggerName: string) {
const workflow = workflowHelpers.getCurrentWorkflow();
const connectedNodes = workflow.getChildNodes(triggerName);
const hasFormPage = connectedNodes.some((nodeName) => {
const node = workflow.getNode(nodeName);
return node && node.type === FORM_NODE_TYPE;
});
if (hasFormPage) {
const triggerParameters: INodeProperties[] = [];
for (const parameter of parameters) {
if (parameter.name === 'responseMode') {
triggerParameters.push({
displayName: 'On submission, the user will be taken to the next form node',
name: 'formResponseModeNotice',
type: 'notice',
default: '',
});
continue;
}
if (parameter.name === ADD_FORM_NOTICE) continue;
if (parameter.name === 'options') {
const options = (parameter.options as INodeProperties[]).filter(
(option) => option.name !== 'respondWithOptions',
);
triggerParameters.push({
...parameter,
options,
});
continue;
}
triggerParameters.push(parameter);
}
return triggerParameters;
}
return parameters;
}
function updateWaitParameters(parameters: INodeProperties[], nodeName: string) {
const workflow = workflowHelpers.getCurrentWorkflow();
const parentNodes = workflow.getParentNodes(nodeName);
const formTriggerName = parentNodes.find(
(node) => workflow.nodes[node].type === FORM_TRIGGER_NODE_TYPE,
);
if (!formTriggerName) return parameters;
const connectedNodes = workflow.getChildNodes(formTriggerName);
const hasFormPage = connectedNodes.some((nodeName) => {
const node = workflow.getNode(nodeName);
return node && node.type === FORM_NODE_TYPE;
});
if (hasFormPage) {
const waitNodeParameters: INodeProperties[] = [];
for (const parameter of parameters) {
if (parameter.name === 'options') {
const options = (parameter.options as INodeProperties[]).filter(
(option) => option.name !== 'respondWithOptions' && option.name !== 'webhookSuffix',
);
waitNodeParameters.push({
...parameter,
options,
});
continue;
}
waitNodeParameters.push(parameter);
}
return waitNodeParameters;
}
return parameters;
}
function updateFormParameters(parameters: INodeProperties[], nodeName: string) {
const workflow = workflowHelpers.getCurrentWorkflow();
const parentNodes = workflow.getParentNodes(nodeName);
const formTriggerName = parentNodes.find(
(node) => workflow.nodes[node].type === FORM_TRIGGER_NODE_TYPE,
);
if (formTriggerName) return parameters.filter((parameter) => parameter.name !== 'triggerNotice');
return parameters;
}
function onParameterBlur(parameterName: string) {
emit('parameterBlur', parameterName);
}
function getCredentialsDependencies() {
const dependencies = new Set();
// Get names of all fields that credentials rendering depends on (using displayOptions > show)
if (nodeType.value?.credentials) {
for (const cred of nodeType.value.credentials) {
if (cred.displayOptions?.show) {
Object.keys(cred.displayOptions.show).forEach((fieldName) => dependencies.add(fieldName));
}
}
}
return dependencies;
}
function multipleValues(parameter: INodeProperties): boolean {
return getArgument('multipleValues', parameter) === true;
}
function getArgument(
argumentName: string,
parameter: INodeProperties,
): string | string[] | number | boolean | undefined {
if (parameter.typeOptions === undefined) {
return undefined;
}
if (parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return parameter.typeOptions[argumentName];
}
function getPath(parameterName: string): string {
return (props.path ? `${props.path}.` : '') + parameterName;
}
function deleteOption(optionName: string): void {
const parameterData = {
name: getPath(optionName),
value: undefined,
};
// TODO: If there is only one option it should delete the whole one
emit('valueChanged', parameterData);
}
function mustHideDuringCustomApiCall(
parameter: INodeProperties,
nodeValues: INodeParameters,
): boolean {
if (parameter?.displayOptions?.hide) return true;
const MUST_REMAIN_VISIBLE = [
'authentication',
'resource',
'operation',
...Object.keys(nodeValues),
];
return !MUST_REMAIN_VISIBLE.includes(parameter.name);
}
function displayNodeParameter(
parameter: INodeProperties,
displayKey: 'displayOptions' | 'disabledOptions' = 'displayOptions',
): boolean {
if (parameter.type === 'hidden') {
return false;
}
if (
nodeHelpers.isCustomApiCallSelected(props.nodeValues) &&
mustHideDuringCustomApiCall(parameter, props.nodeValues)
) {
return false;
}
// Hide authentication related fields since it will now be part of credentials modal
if (
!KEEP_AUTH_IN_NDV_FOR_NODES.includes(node.value?.type || '') &&
mainNodeAuthField.value &&
(parameter.name === mainNodeAuthField.value?.name || shouldHideAuthRelatedParameter(parameter))
) {
return false;
}
if (parameter[displayKey] === undefined) {
// If it is not defined no need to do a proper check
return true;
}
const nodeValues: INodeParameters = {};
let rawValues = props.nodeValues;
if (props.path) {
rawValues = get(props.nodeValues, props.path) as INodeParameters;
}
if (!rawValues) {
return false;
}
// Resolve expressions
const resolveKeys = Object.keys(rawValues);
let key: string;
let i = 0;
let parameterGotResolved = false;
do {
key = resolveKeys.shift() as string;
const value = rawValues[key];
if (typeof value === 'string' && value?.charAt(0) === '=') {
// Contains an expression that
if (
value.includes('$parameter') &&
resolveKeys.some((parameterName) => value.includes(parameterName))
) {
// Contains probably an expression of a missing parameter so skip
resolveKeys.push(key);
continue;
} else {
// Contains probably no expression with a missing parameter so resolve
try {
nodeValues[key] = workflowHelpers.resolveExpression(
value,
nodeValues,
) as NodeParameterValue;
} catch (e) {
// If expression is invalid ignore
nodeValues[key] = '';
}
parameterGotResolved = true;
}
} else {
// Does not contain an expression, add directly
nodeValues[key] = rawValues[key];
}
// TODO: Think about how to calculate this best
if (i++ > 50) {
// Make sure we do not get caught
break;
}
} while (resolveKeys.length !== 0);
if (parameterGotResolved) {
if (props.path) {
rawValues = deepCopy(props.nodeValues);
set(rawValues, props.path, nodeValues);
return nodeHelpers.displayParameter(rawValues, parameter, props.path, node.value, displayKey);
} else {
return nodeHelpers.displayParameter(nodeValues, parameter, '', node.value, displayKey);
}
}
return nodeHelpers.displayParameter(
props.nodeValues,
parameter,
props.path,
node.value,
displayKey,
);
}
function valueChanged(parameterData: IUpdateInformation): void {
emit('valueChanged', parameterData);
}
function onNoticeAction(action: string) {
if (action === 'activate') {
emit('activate');
}
}
function getParameterIssues(parameter: INodeProperties): string[] {
if (!node.value || !showIssuesInLabelFor.includes(parameter.type)) {
return [];
}
const issues = NodeHelpers.getParameterIssues(
parameter,
node.value.parameters,
'',
node.value,
nodeType.value,
);
return issues.parameters?.[parameter.name] ?? [];
}
/**
* Handles default node button parameter type actions
* @param parameter
*/
function shouldHideAuthRelatedParameter(parameter: INodeProperties): boolean {
// TODO: For now, hide all fields that are used in authentication fields displayOptions
// Ideally, we should check if any non-auth field depends on it before hiding it but
// since there is no such case, omitting it to avoid additional computation
return isAuthRelatedParameter(nodeAuthFields.value, parameter);
}
function shouldShowOptions(parameter: INodeProperties): boolean {
return parameter.type !== 'resourceMapper';
}
function getDependentParametersValues(parameter: INodeProperties): string | null {
const loadOptionsDependsOn = getArgument('loadOptionsDependsOn', parameter) as
| string[]
| undefined;
if (loadOptionsDependsOn === undefined) {
return null;
}
// Get the resolved parameter values of the current node
const currentNodeParameters = ndvStore.activeNode?.parameters;
try {
const resolvedNodeParameters = workflowHelpers.resolveParameter(currentNodeParameters);
const returnValues: string[] = [];
for (const parameterPath of loadOptionsDependsOn) {
returnValues.push(get(resolvedNodeParameters, parameterPath) as string);
}
return returnValues.join('|');
} catch (error) {
return null;
}
}
function getParameterValue<T extends NodeParameterValueType = NodeParameterValueType>(
name: string,
): T {
return nodeHelpers.getParameterValue(props.nodeValues, name, props.path) as T;
}
function isRagStarterCallout(parameter: INodeProperties): boolean {
return parameter.type === 'callout' && parameter.name === 'ragStarterCallout';
}
function isCalloutVisible(parameter: INodeProperties): boolean {
if (isCalloutDismissed(parameter.name)) return false;
if (isRagStarterCallout(parameter)) {
return isRagStarterCalloutVisible.value;
}
return true;
}
async function onCalloutAction(action: CalloutActionType) {
if (action === 'openRagStarterTemplate') {
await openRagStarterTemplate(activeNode.value?.type ?? 'no active node');
}
}
const onCalloutDismiss = async (parameter: INodeProperties) => {
const dismissConfirmed = await message.confirm(
i18n.baseText('parameterInputList.callout.dismiss.confirm.text'),
{
showClose: true,
confirmButtonText: i18n.baseText(
'parameterInputList.callout.dismiss.confirm.confirmButtonText',
),
cancelButtonText: i18n.baseText(
'parameterInputList.callout.dismiss.confirm.cancelButtonText',
),
},
);
if (dismissConfirmed !== MODAL_CONFIRM) {
return;
}
await dismissCallout(parameter.name);
};
</script>
<template>
<div class="parameter-input-list-wrapper">
<div
v-for="(parameter, index) in filteredParameters"
:key="parameter.name"
:class="{ indent }"
data-test-id="parameter-item"
>
<slot v-if="indexToShowSlotAt === index" />
<div
v-if="multipleValues(parameter) === true && parameter.type !== 'fixedCollection'"
class="parameter-item"
>
<MultipleParameter
:parameter="parameter"
:values="getParameterValue(parameter.name)"
:node-values="nodeValues"
:path="getPath(parameter.name)"
:is-read-only="isReadOnly"
@value-changed="valueChanged"
/>
</div>
<ImportCurlParameter
v-else-if="parameter.type === 'curlImport'"
:is-read-only="isReadOnly"
@value-changed="valueChanged"
/>
<N8nNotice
v-else-if="parameter.type === 'notice'"
:class="['parameter-item', parameter.typeOptions?.containerClass ?? '']"
:content="i18n.nodeText(activeNode?.type).inputLabelDisplayName(parameter, path)"
@action="onNoticeAction"
/>
<template v-else-if="parameter.type === 'callout'">
<N8nCallout
v-if="isCalloutVisible(parameter)"
:class="['parameter-item', parameter.typeOptions?.containerClass ?? '']"
theme="secondary"
>
<N8nText size="small">
<N8nText
size="small"
v-n8n-html="i18n.nodeText(activeNode?.type).inputLabelDisplayName(parameter, path)"
/>
<template v-if="parameter.typeOptions?.calloutAction">
{{ ' ' }}
<N8nLink
v-if="parameter.typeOptions?.calloutAction"
theme="secondary"
size="small"
:bold="true"
:underline="true"
@click="onCalloutAction(parameter.typeOptions.calloutAction.type)"
>
{{ parameter.typeOptions.calloutAction.label }}
</N8nLink>
</template>
</N8nText>
<template #trailingContent>
<N8nIcon
icon="times"
title="Dismiss"
size="medium"
type="secondary"
class="callout-dismiss"
data-test-id="callout-dismiss-icon"
@click="onCalloutDismiss(parameter)"
/>
</template>
</N8nCallout>
</template>
<div v-else-if="parameter.type === 'button'" class="parameter-item">
<ButtonParameter
:parameter="parameter"
:path="path"
:value="getParameterValue(parameter.name)"
:is-read-only="isReadOnly"
@value-changed="valueChanged"
/>
</div>
<div
v-else-if="['collection', 'fixedCollection'].includes(parameter.type)"
class="multi-parameter"
>
<N8nInputLabel
:label="i18n.nodeText(activeNode?.type).inputLabelDisplayName(parameter, path)"
:tooltip-text="i18n.nodeText(activeNode?.type).inputLabelDescription(parameter, path)"
size="small"
:underline="true"
:input-name="parameter.name"
color="text-dark"
>
<template
v-if="
showIssuesInLabelFor.includes(parameter.type) &&
getParameterIssues(parameter).length > 0
"
#issues
>
<N8nTooltip>
<template #content>
<span v-for="(issue, i) in getParameterIssues(parameter)" :key="i">{{
issue
}}</span>
</template>
<N8nIcon icon="exclamation-triangle" size="small" color="danger" />
</N8nTooltip>
</template>
</N8nInputLabel>
<Suspense v-if="!asyncLoadingError">
<template #default>
<LazyCollectionParameter
v-if="parameter.type === 'collection'"
:parameter="parameter"
:values="getParameterValue(parameter.name)"
:node-values="nodeValues"
:path="getPath(parameter.name)"
:is-read-only="isReadOnly"
@value-changed="valueChanged"
/>
<LazyFixedCollectionParameter
v-else-if="parameter.type === 'fixedCollection'"
:parameter="parameter"
:values="getParameterValue(parameter.name)"
:node-values="nodeValues"
:path="getPath(parameter.name)"
:is-read-only="isReadOnly"
@value-changed="valueChanged"
/>
</template>
<template #fallback>
<N8nText size="small" class="async-notice">
<N8nIcon icon="sync-alt" size="xsmall" :spin="true" />
{{ i18n.baseText('parameterInputList.loadingFields') }}
</N8nText>
</template>
</Suspense>
<N8nText v-else size="small" color="danger" class="async-notice">
<N8nIcon icon="exclamation-triangle" size="xsmall" />
{{ i18n.baseText('parameterInputList.loadingError') }}
</N8nText>
<N8nIconButton
v-if="hideDelete !== true && !isReadOnly && !parameter.isNodeSetting"
type="tertiary"
text
size="mini"
icon="trash"
class="icon-button"
:title="i18n.baseText('parameterInputList.delete')"
@click="deleteOption(parameter.name)"
></N8nIconButton>
</div>
<ResourceMapper
v-else-if="parameter.type === 'resourceMapper'"
:parameter="parameter"
:node="node"
:path="getPath(parameter.name)"
:dependent-parameters-values="getDependentParametersValues(parameter)"
:is-read-only="isReadOnly"
input-size="small"
label-size="small"
@value-changed="valueChanged"
/>
<FilterConditions
v-else-if="parameter.type === 'filter'"
:parameter="parameter"
:value="getParameterValue(parameter.name)"
:path="getPath(parameter.name)"
:node="node"
:read-only="isReadOnly"
@value-changed="valueChanged"
/>
<AssignmentCollection
v-else-if="parameter.type === 'assignmentCollection'"
:parameter="parameter"
:value="getParameterValue(parameter.name)"
:path="getPath(parameter.name)"
:node="node"
:is-read-only="isReadOnly"
:default-type="parameter.typeOptions?.assignment?.defaultType"
:disable-type="parameter.typeOptions?.assignment?.disableType"
@value-changed="valueChanged"
/>
<div v-else-if="credentialsParameterIndex !== index" class="parameter-item">
<N8nIconButton
v-if="hideDelete !== true && !isReadOnly && !parameter.isNodeSetting"
type="tertiary"
text
size="mini"
icon="trash"
class="icon-button"
:title="i18n.baseText('parameterInputList.delete')"
@click="deleteOption(parameter.name)"
></N8nIconButton>
<ParameterInputFull
:parameter="parameter"
:hide-issues="hiddenIssuesInputs.includes(parameter.name)"
:value="getParameterValue(parameter.name)"
:display-options="shouldShowOptions(parameter)"
:path="getPath(parameter.name)"
:is-read-only="
isReadOnly ||
(parameter.disabledOptions && displayNodeParameter(parameter, 'disabledOptions'))
"
:hide-label="false"
:node-values="nodeValues"
@update="valueChanged"
@blur="onParameterBlur(parameter.name)"
/>
</div>
</div>
<div v-if="filteredParameters.length === 0" :class="{ indent }">
<slot />
</div>
</div>
</template>
<style lang="scss">
.parameter-input-list-wrapper {
--disabled-fill: var(--color-background-base);
.icon-button {
position: absolute;
opacity: 0;
top: 0;
left: calc(-0.5 * var(--spacing-2xs));
transition: opacity 100ms ease-in;
Button {
color: var(--color-icon-base);
}
}
.icon-button > Button:hover {
color: var(--color-icon-hover);
}
.indent > div {
padding-left: var(--spacing-s);
}
.multi-parameter {
position: relative;
margin: var(--spacing-xs) 0;
.parameter-info {
display: none;
}
}
.parameter-item {
position: relative;
margin: var(--spacing-xs) 0;
}
.parameter-item:hover > .icon-button,
.multi-parameter:hover > .icon-button {
opacity: 1;
}
.parameter-notice {
background-color: var(--color-warning-tint-2);
color: $custom-font-black;
margin: 0.3em 0;
padding: 0.7em;
a {
font-weight: var(--font-weight-bold);
}
}
.async-notice {
display: block;
padding: var(--spacing-3xs) 0;
}
.callout-dismiss {
margin-left: var(--spacing-xs);
line-height: 1;
cursor: pointer;
}
.callout-dismiss:hover {
color: var(--color-icon-hover);
}
}
</style>