refactor(editor): Extract large chunks from NodeSettings (#17665)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Suguru Inoue
2025-07-29 15:13:38 +02:00
committed by GitHub
parent b64c8ad3a4
commit d16960bad3
6 changed files with 367 additions and 350 deletions

View File

@@ -49,7 +49,6 @@ const emit = defineEmits<{
connectionType: NodeConnectionType,
connectionIndex?: number,
];
redrawNode: [nodeName: string];
stopExecution: [];
}>();
@@ -81,7 +80,6 @@ const message = useMessage();
const { APP_Z_INDEXES } = useStyles();
const settingsEventBus = createEventBus();
const redrawRequired = ref(false);
const runInputIndex = ref(-1);
const runOutputIndex = computed(() => ndvStore.output.run ?? -1);
const selectedInput = ref<string | undefined>();
@@ -498,18 +496,6 @@ const close = async () => {
return;
}
if (
activeNode.value &&
(typeof activeNodeType.value?.outputs === 'string' ||
typeof activeNodeType.value?.inputs === 'string' ||
redrawRequired.value)
) {
const nodeName = activeNode.value.name;
setTimeout(() => {
emit('redrawNode', nodeName);
}, 1);
}
if (outputPanelEditMode.value.enabled && activeNode.value) {
const shouldPinDataBeforeClosing = await message.confirm(
'',
@@ -842,7 +828,6 @@ onBeforeUnmount(() => {
@value-changed="valueChanged"
@execute="onNodeExecute"
@stop-execution="onStopExecution"
@redraw-required="redrawRequired = true"
@activate="onWorkflowActivate"
@switch-selected-node="onSwitchSelectedNode"
@open-connection-node-creator="onOpenConnectionNodeCreator"

View File

@@ -85,7 +85,6 @@ const message = useMessage();
const { APP_Z_INDEXES } = useStyles();
const settingsEventBus = createEventBus();
const redrawRequired = ref(false);
const runInputIndex = ref(-1);
const runOutputIndex = ref(-1);
const isLinkingEnabled = ref(true);
@@ -821,7 +820,6 @@ onBeforeUnmount(() => {
:class="$style.settings"
@execute="onNodeExecute"
@stop-execution="onStopExecution"
@redraw-required="redrawRequired = true"
@activate="onWorkflowActivate"
@switch-selected-node="onSwitchSelectedNode"
@open-connection-node-creator="onOpenConnectionNodeCreator"

View File

@@ -2,7 +2,6 @@
import { useTemplateRef, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type {
INodeParameters,
INodeProperties,
NodeConnectionType,
NodeParameterValue,
INodeCredentialDescription,
@@ -16,12 +15,7 @@ import type {
IUpdateInformation,
} from '@/Interface';
import {
BASE_NODE_SURVEY_URL,
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
CUSTOM_NODES_DOCS_URL,
NDV_UI_OVERHAUL_EXPERIMENT,
} from '@/constants';
import { BASE_NODE_SURVEY_URL, NDV_UI_OVERHAUL_EXPERIMENT } from '@/constants';
import ParameterInputList from '@/components/ParameterInputList.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
@@ -32,7 +26,12 @@ import NodeSettingsHeader from '@/components/NodeSettingsHeader.vue';
import get from 'lodash/get';
import NodeExecuteButton from './NodeExecuteButton.vue';
import { nameIsParameter } from '@/utils/nodeSettingsUtils';
import {
collectSettings,
createCommonNodeSettings,
nameIsParameter,
getNodeSettingsInitialValues,
} from '@/utils/nodeSettingsUtils';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store';
@@ -54,9 +53,9 @@ import { usePostHog } from '@/stores/posthog.store';
import { shouldShowParameter } from './canvas/experimental/experimentalNdv.utils';
import { useResizeObserver } from '@vueuse/core';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { I18nT } from 'vue-i18n';
import { N8nBlockUi, N8nIcon, N8nLink, N8nNotice, N8nText } from '@n8n/design-system';
import { N8nBlockUi, N8nIcon, N8nNotice, N8nText } from '@n8n/design-system';
import ExperimentalEmbeddedNdvHeader from '@/components/canvas/experimental/components/ExperimentalEmbeddedNdvHeader.vue';
import NodeSettingsInvalidNodeWarning from '@/components/NodeSettingsInvalidNodeWarning.vue';
const props = withDefaults(
defineProps<{
@@ -86,7 +85,6 @@ const props = withDefaults(
const emit = defineEmits<{
stopExecution: [];
redrawRequired: [];
valueChanged: [value: IUpdateInformation];
switchSelectedNode: [nodeName: string];
openConnectionNodeCreator: [
@@ -101,18 +99,7 @@ const emit = defineEmits<{
const slots = defineSlots<{ actions?: {} }>();
const nodeValues = ref<INodeParameters>({
color: '#ff0000',
alwaysOutputData: false,
executeOnce: false,
notesInFlow: false,
onError: 'stopWorkflow',
retryOnFail: false,
maxTries: 3,
waitBetweenTries: 1000,
notes: '',
parameters: {},
});
const nodeValues = ref<INodeParameters>(getNodeSettingsInitialValues());
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
@@ -145,7 +132,6 @@ const openPanel = ref<Tab>('params');
const nodeValuesInitialized = ref(false);
const hiddenIssuesInputs = ref<string[]>([]);
const nodeSettings = ref<INodeProperties[]>([]);
const subConnections = ref<InstanceType<typeof NDVSubConnections> | null>(null);
const installedPackage = ref<PublicInstalledPackage | undefined>(undefined);
@@ -306,11 +292,6 @@ const valueChanged = (parameterData: IUpdateInformation) => {
return;
}
if (parameterData.name === 'onError') {
// If that parameter changes, we need to redraw the connections, as the error output may need to be added or removed
emit('redrawRequired');
}
if (parameterData.name === 'name') {
// Name of node changed so we have to set also the new node name as active
@@ -456,132 +437,9 @@ const populateHiddenIssuesSet = () => {
workflowsStore.setNodePristine(node.value.name, false);
};
const populateSettings = () => {
if (isExecutable.value && !isTriggerNode.value) {
nodeSettings.value.push(
...([
{
displayName: i18n.baseText('nodeSettings.alwaysOutputData.displayName'),
name: 'alwaysOutputData',
type: 'boolean',
default: false,
noDataExpression: true,
description: i18n.baseText('nodeSettings.alwaysOutputData.description'),
isNodeSetting: true,
},
{
displayName: i18n.baseText('nodeSettings.executeOnce.displayName'),
name: 'executeOnce',
type: 'boolean',
default: false,
noDataExpression: true,
description: i18n.baseText('nodeSettings.executeOnce.description'),
isNodeSetting: true,
},
{
displayName: i18n.baseText('nodeSettings.retryOnFail.displayName'),
name: 'retryOnFail',
type: 'boolean',
default: false,
noDataExpression: true,
description: i18n.baseText('nodeSettings.retryOnFail.description'),
isNodeSetting: true,
},
{
displayName: i18n.baseText('nodeSettings.maxTries.displayName'),
name: 'maxTries',
type: 'number',
typeOptions: {
minValue: 2,
maxValue: 5,
},
default: 3,
displayOptions: {
show: {
retryOnFail: [true],
},
},
noDataExpression: true,
description: i18n.baseText('nodeSettings.maxTries.description'),
isNodeSetting: true,
},
{
displayName: i18n.baseText('nodeSettings.waitBetweenTries.displayName'),
name: 'waitBetweenTries',
type: 'number',
typeOptions: {
minValue: 0,
maxValue: 5000,
},
default: 1000,
displayOptions: {
show: {
retryOnFail: [true],
},
},
noDataExpression: true,
description: i18n.baseText('nodeSettings.waitBetweenTries.description'),
isNodeSetting: true,
},
{
displayName: i18n.baseText('nodeSettings.onError.displayName'),
name: 'onError',
type: 'options',
options: [
{
name: i18n.baseText('nodeSettings.onError.options.stopWorkflow.displayName'),
value: 'stopWorkflow',
description: i18n.baseText('nodeSettings.onError.options.stopWorkflow.description'),
},
{
name: i18n.baseText('nodeSettings.onError.options.continueRegularOutput.displayName'),
value: 'continueRegularOutput',
description: i18n.baseText(
'nodeSettings.onError.options.continueRegularOutput.description',
),
},
{
name: i18n.baseText('nodeSettings.onError.options.continueErrorOutput.displayName'),
value: 'continueErrorOutput',
description: i18n.baseText(
'nodeSettings.onError.options.continueErrorOutput.description',
),
},
],
default: 'stopWorkflow',
description: i18n.baseText('nodeSettings.onError.description'),
noDataExpression: true,
isNodeSetting: true,
},
] as INodeProperties[]),
);
}
nodeSettings.value.push(
...([
{
displayName: i18n.baseText('nodeSettings.notes.displayName'),
name: 'notes',
type: 'string',
typeOptions: {
rows: 5,
},
default: '',
noDataExpression: true,
description: i18n.baseText('nodeSettings.notes.description'),
isNodeSetting: true,
},
{
displayName: i18n.baseText('nodeSettings.notesInFlow.displayName'),
name: 'notesInFlow',
type: 'boolean',
default: false,
noDataExpression: true,
description: i18n.baseText('nodeSettings.notesInFlow.description'),
isNodeSetting: true,
},
] as INodeProperties[]),
);
};
const nodeSettings = computed(() =>
createCommonNodeSettings(isExecutable.value, isToolNode.value, i18n.baseText.bind(i18n)),
);
const onParameterBlur = (parameterName: string) => {
hiddenIssuesInputs.value = hiddenIssuesInputs.value.filter((name) => name !== parameterName);
@@ -631,103 +489,7 @@ const setNodeValues = () => {
if (nodeType.value !== null) {
nodeValid.value = true;
const foundNodeSettings = [];
if (node.value.color) {
foundNodeSettings.push('color');
nodeValues.value = {
...nodeValues.value,
color: node.value.color,
};
}
if (node.value.notes) {
foundNodeSettings.push('notes');
nodeValues.value = {
...nodeValues.value,
notes: node.value.notes,
};
}
if (node.value.alwaysOutputData) {
foundNodeSettings.push('alwaysOutputData');
nodeValues.value = {
...nodeValues.value,
alwaysOutputData: node.value.alwaysOutputData,
};
}
if (node.value.executeOnce) {
foundNodeSettings.push('executeOnce');
nodeValues.value = {
...nodeValues.value,
executeOnce: node.value.executeOnce,
};
}
if (node.value.continueOnFail) {
foundNodeSettings.push('onError');
nodeValues.value = {
...nodeValues.value,
onError: 'continueRegularOutput',
};
}
if (node.value.onError) {
foundNodeSettings.push('onError');
nodeValues.value = {
...nodeValues.value,
onError: node.value.onError,
};
}
if (node.value.notesInFlow) {
foundNodeSettings.push('notesInFlow');
nodeValues.value = {
...nodeValues.value,
notesInFlow: node.value.notesInFlow,
};
}
if (node.value.retryOnFail) {
foundNodeSettings.push('retryOnFail');
nodeValues.value = {
...nodeValues.value,
retryOnFail: node.value.retryOnFail,
};
}
if (node.value.maxTries) {
foundNodeSettings.push('maxTries');
nodeValues.value = {
...nodeValues.value,
maxTries: node.value.maxTries,
};
}
if (node.value.waitBetweenTries) {
foundNodeSettings.push('waitBetweenTries');
nodeValues.value = {
...nodeValues.value,
waitBetweenTries: node.value.waitBetweenTries,
};
}
// Set default node settings
for (const nodeSetting of nodeSettings.value) {
if (!foundNodeSettings.includes(nodeSetting.name)) {
// Set default value
nodeValues.value = {
...nodeValues.value,
[nodeSetting.name]: nodeSetting.default,
};
}
}
nodeValues.value = {
...nodeValues.value,
parameters: deepCopy(node.value.parameters),
};
nodeValues.value = collectSettings(node.value, nodeSettings.value);
} else {
nodeValid.value = false;
}
@@ -735,22 +497,6 @@ const setNodeValues = () => {
nodeValuesInitialized.value = true;
};
const onMissingNodeTextClick = (event: MouseEvent) => {
if ((event.target as Element).localName === 'a') {
telemetry.track('user clicked cnr browse button', {
source: 'cnr missing node modal',
});
}
};
const onMissingNodeLearnMoreLinkClick = () => {
telemetry.track('user clicked cnr docs link', {
source: 'missing node modal source',
package_name: node.value?.type.split('.')[0],
node_type: node.value?.type,
});
};
const onStopExecution = () => {
emit('stopExecution');
};
@@ -782,7 +528,6 @@ watch(node, () => {
onMounted(async () => {
populateHiddenIssuesSet();
populateSettings();
setNodeValues();
props.eventBus?.on('openSettings', openSettings);
if (node.value !== null) {
@@ -885,49 +630,9 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
@value-changed="valueChanged"
@tab-changed="onTabSelect"
/>
<div v-if="node && !nodeValid" class="node-is-not-valid">
<p :class="$style.warningIcon">
<N8nIcon icon="triangle-alert" />
</p>
<div class="missingNodeTitleContainer mt-s mb-xs">
<N8nText size="large" color="text-dark" bold>
{{ i18n.baseText('nodeSettings.communityNodeUnknown.title') }}
</N8nText>
</div>
<div v-if="isCommunityNode" :class="$style.descriptionContainer">
<div class="mb-l">
<I18nT
keypath="nodeSettings.communityNodeUnknown.description"
tag="span"
scope="global"
@click="onMissingNodeTextClick"
>
<template #action>
<a
:href="`https://www.npmjs.com/package/${node.type.split('.')[0]}`"
target="_blank"
>{{ node.type.split('.')[0] }}</a
>
</template>
</I18nT>
</div>
<N8nLink
:to="COMMUNITY_NODES_INSTALLATION_DOCS_URL"
@click="onMissingNodeLearnMoreLinkClick"
>
{{ i18n.baseText('nodeSettings.communityNodeUnknown.installLink.text') }}
</N8nLink>
</div>
<I18nT v-else keypath="nodeSettings.nodeTypeUnknown.description" tag="span" scope="global">
<template #action>
<a
:href="CUSTOM_NODES_DOCS_URL"
target="_blank"
v-text="i18n.baseText('nodeSettings.nodeTypeUnknown.description.customNode')"
/>
</template>
</I18nT>
</div>
<NodeSettingsInvalidNodeWarning v-if="node && !nodeValid" :node="node" />
<div
v-if="node && nodeValid"
ref="nodeParameterWrapper"
@@ -1060,16 +765,6 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
background-color: var(--color-background-base);
}
.warningIcon {
color: var(--color-text-lighter);
font-size: var(--font-size-2xl);
}
.descriptionContainer {
display: flex;
flex-direction: column;
}
.featureRequest {
margin-top: auto;
align-self: center;
@@ -1112,17 +807,6 @@ function displayCredentials(credentialTypeDescription: INodeCredentialDescriptio
}
}
.node-is-not-valid {
height: 75%;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
line-height: var(--font-line-height-regular);
}
.node-parameters-wrapper {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { useTelemetry } from '@/composables/useTelemetry';
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL, CUSTOM_NODES_DOCS_URL } from '@/constants';
import type { INodeUi } from '@/Interface';
import { isCommunityPackageName } from '@/utils/nodeTypesUtils';
import { N8nIcon, N8nLink, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { I18nT } from 'vue-i18n';
const { node } = defineProps<{ node: INodeUi }>();
const i18n = useI18n();
const telemetry = useTelemetry();
const isCommunityNode = computed(() => isCommunityPackageName(node.type));
const npmPackage = computed(() => node.type.split('.')[0]);
function onMissingNodeTextClick(event: MouseEvent) {
if (event.target instanceof Element && event.target.localName === 'a') {
telemetry.track('user clicked cnr browse button', {
source: 'cnr missing node modal',
});
}
}
function onMissingNodeLearnMoreLinkClick() {
telemetry.track('user clicked cnr docs link', {
source: 'missing node modal source',
package_name: node.type.split('.')[0],
node_type: node.type,
});
}
</script>
<template>
<div :class="$style.nodeIsNotValid">
<p :class="$style.warningIcon">
<N8nIcon icon="triangle-alert" />
</p>
<div class="mt-s mb-xs">
<N8nText size="large" color="text-dark" bold>
{{ i18n.baseText('nodeSettings.communityNodeUnknown.title') }}
</N8nText>
</div>
<div v-if="isCommunityNode" :class="$style.descriptionContainer">
<div class="mb-l">
<I18nT
keypath="nodeSettings.communityNodeUnknown.description"
tag="span"
scope="global"
@click="onMissingNodeTextClick"
>
<template #action>
<a
:href="`https://www.npmjs.com/package/${npmPackage}`"
target="_blank"
rel="noopener noreferrer"
>
{{ npmPackage }}
</a>
</template>
</I18nT>
</div>
<N8nLink :to="COMMUNITY_NODES_INSTALLATION_DOCS_URL" @click="onMissingNodeLearnMoreLinkClick">
{{ i18n.baseText('nodeSettings.communityNodeUnknown.installLink.text') }}
</N8nLink>
</div>
<I18nT v-else keypath="nodeSettings.nodeTypeUnknown.description" tag="span" scope="global">
<template #action>
<a
:href="CUSTOM_NODES_DOCS_URL"
target="_blank"
v-text="i18n.baseText('nodeSettings.nodeTypeUnknown.description.customNode')"
/>
</template>
</I18nT>
</div>
</template>
<style lang="scss" module>
.nodeIsNotValid {
height: 75%;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
line-height: var(--font-line-height-regular);
}
.warningIcon {
color: var(--color-text-lighter);
font-size: var(--font-size-2xl);
}
.descriptionContainer {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -30,6 +30,22 @@ import { captureException } from '@sentry/vue';
import { isPresent } from './typesUtils';
import type { Ref } from 'vue';
import { omitKey } from './objectUtils';
import type { BaseTextKey } from '@n8n/i18n';
export function getNodeSettingsInitialValues(): INodeParameters {
return {
color: '#ff0000',
alwaysOutputData: false,
executeOnce: false,
notesInFlow: false,
onError: 'stopWorkflow',
retryOnFail: false,
maxTries: 3,
waitBetweenTries: 1000,
notes: '',
parameters: {},
};
}
export function setValue(
nodeValues: Ref<INodeParameters>,
@@ -448,3 +464,235 @@ export function shouldSkipParamValidation(
Boolean(parameter.allowArbitraryValues))
);
}
export function createCommonNodeSettings(
isExecutable: boolean,
isTriggerNode: boolean,
t: (key: BaseTextKey) => string,
) {
const ret: INodeProperties[] = [];
if (isExecutable && !isTriggerNode) {
ret.push(
{
displayName: t('nodeSettings.alwaysOutputData.displayName'),
name: 'alwaysOutputData',
type: 'boolean',
default: false,
noDataExpression: true,
description: t('nodeSettings.alwaysOutputData.description'),
isNodeSetting: true,
},
{
displayName: t('nodeSettings.executeOnce.displayName'),
name: 'executeOnce',
type: 'boolean',
default: false,
noDataExpression: true,
description: t('nodeSettings.executeOnce.description'),
isNodeSetting: true,
},
{
displayName: t('nodeSettings.retryOnFail.displayName'),
name: 'retryOnFail',
type: 'boolean',
default: false,
noDataExpression: true,
description: t('nodeSettings.retryOnFail.description'),
isNodeSetting: true,
},
{
displayName: t('nodeSettings.maxTries.displayName'),
name: 'maxTries',
type: 'number',
typeOptions: {
minValue: 2,
maxValue: 5,
},
default: 3,
displayOptions: {
show: {
retryOnFail: [true],
},
},
noDataExpression: true,
description: t('nodeSettings.maxTries.description'),
isNodeSetting: true,
},
{
displayName: t('nodeSettings.waitBetweenTries.displayName'),
name: 'waitBetweenTries',
type: 'number',
typeOptions: {
minValue: 0,
maxValue: 5000,
},
default: 1000,
displayOptions: {
show: {
retryOnFail: [true],
},
},
noDataExpression: true,
description: t('nodeSettings.waitBetweenTries.description'),
isNodeSetting: true,
},
{
displayName: t('nodeSettings.onError.displayName'),
name: 'onError',
type: 'options',
options: [
{
name: t('nodeSettings.onError.options.stopWorkflow.displayName'),
value: 'stopWorkflow',
description: t('nodeSettings.onError.options.stopWorkflow.description'),
},
{
name: t('nodeSettings.onError.options.continueRegularOutput.displayName'),
value: 'continueRegularOutput',
description: t('nodeSettings.onError.options.continueRegularOutput.description'),
},
{
name: t('nodeSettings.onError.options.continueErrorOutput.displayName'),
value: 'continueErrorOutput',
description: t('nodeSettings.onError.options.continueErrorOutput.description'),
},
],
default: 'stopWorkflow',
description: t('nodeSettings.onError.description'),
noDataExpression: true,
isNodeSetting: true,
},
);
}
ret.push(
{
displayName: t('nodeSettings.notes.displayName'),
name: 'notes',
type: 'string',
typeOptions: {
rows: 5,
},
default: '',
noDataExpression: true,
description: t('nodeSettings.notes.description'),
isNodeSetting: true,
},
{
displayName: t('nodeSettings.notesInFlow.displayName'),
name: 'notesInFlow',
type: 'boolean',
default: false,
noDataExpression: true,
description: t('nodeSettings.notesInFlow.description'),
isNodeSetting: true,
},
);
return ret;
}
export function collectSettings(node: INodeUi, nodeSettings: INodeProperties[]): INodeParameters {
let ret = getNodeSettingsInitialValues();
const foundNodeSettings = [];
if (node.color) {
foundNodeSettings.push('color');
ret = {
...ret,
color: node.color,
};
}
if (node.notes) {
foundNodeSettings.push('notes');
ret = {
...ret,
notes: node.notes,
};
}
if (node.alwaysOutputData) {
foundNodeSettings.push('alwaysOutputData');
ret = {
...ret,
alwaysOutputData: node.alwaysOutputData,
};
}
if (node.executeOnce) {
foundNodeSettings.push('executeOnce');
ret = {
...ret,
executeOnce: node.executeOnce,
};
}
if (node.continueOnFail) {
foundNodeSettings.push('onError');
ret = {
...ret,
onError: 'continueRegularOutput',
};
}
if (node.onError) {
foundNodeSettings.push('onError');
ret = {
...ret,
onError: node.onError,
};
}
if (node.notesInFlow) {
foundNodeSettings.push('notesInFlow');
ret = {
...ret,
notesInFlow: node.notesInFlow,
};
}
if (node.retryOnFail) {
foundNodeSettings.push('retryOnFail');
ret = {
...ret,
retryOnFail: node.retryOnFail,
};
}
if (node.maxTries) {
foundNodeSettings.push('maxTries');
ret = {
...ret,
maxTries: node.maxTries,
};
}
if (node.waitBetweenTries) {
foundNodeSettings.push('waitBetweenTries');
ret = {
...ret,
waitBetweenTries: node.waitBetweenTries,
};
}
// Set default node settings
for (const nodeSetting of nodeSettings) {
if (!foundNodeSettings.includes(nodeSetting.name)) {
// Set default value
ret = {
...ret,
[nodeSetting.name]: nodeSetting.default,
};
}
}
ret = {
...ret,
parameters: deepCopy(node.parameters),
};
return ret;
}