feat(editor): Show the right editor in focus panel (#17062)

Co-authored-by: Charlie Kolb <charlie@n8n.io>
This commit is contained in:
Daria
2025-07-09 14:40:39 +03:00
committed by GitHub
parent c37397cb2b
commit 3aeb622978
14 changed files with 464 additions and 220 deletions

View File

@@ -1497,6 +1497,7 @@
"nodeView.deletesTheCurrentExecutionData": "Deletes the current execution data", "nodeView.deletesTheCurrentExecutionData": "Deletes the current execution data",
"nodeView.focusPanel.title": "Focus", "nodeView.focusPanel.title": "Focus",
"nodeView.focusPanel.noParameters": "No parameters focused. Focus a parameter by clicking on the action dropdown in the node detail view.", "nodeView.focusPanel.noParameters": "No parameters focused. Focus a parameter by clicking on the action dropdown in the node detail view.",
"nodeView.focusPanel.missingParameter": "This parameter is no longer visible on the node. A related parameter was likely changed, removing this one.",
"nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.", "nodeView.itLooksLikeYouHaveBeenEditingSomething": "It looks like you made some edits. If you leave before saving, your changes will be lost.",
"nodeView.loadingTemplate": "Loading template", "nodeView.loadingTemplate": "Loading template",
"nodeView.moreInfo": "More info", "nodeView.moreInfo": "More info",

View File

@@ -19,6 +19,7 @@ import { CODE_PLACEHOLDERS } from './constants';
import { useLinter } from './linter'; import { useLinter } from './linter';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop'; import { dropInCodeEditor } from '@/plugins/codemirror/dragAndDrop';
import type { TargetNodeParameterContext } from '@/Interface';
type Props = { type Props = {
mode: CodeExecutionMode; mode: CodeExecutionMode;
@@ -29,6 +30,8 @@ type Props = {
isReadOnly?: boolean; isReadOnly?: boolean;
rows?: number; rows?: number;
id?: string; id?: string;
targetNodeParameterContext?: TargetNodeParameterContext;
disableAskAi?: boolean;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -38,6 +41,8 @@ const props = withDefaults(defineProps<Props>(), {
isReadOnly: false, isReadOnly: false,
rows: 4, rows: 4,
id: () => crypto.randomUUID(), id: () => crypto.randomUUID(),
targetNodeParameterContext: undefined,
disableAskAi: false,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: string]; 'update:modelValue': [value: string];
@@ -81,6 +86,7 @@ const { highlightLine, readEditorValue, editor } = useCodeEditor({
rows: props.rows, rows: props.rows,
}, },
onChange: onEditorUpdate, onChange: onEditorUpdate,
targetNodeParameterContext: () => props.targetNodeParameterContext,
}); });
onMounted(() => { onMounted(() => {
@@ -98,7 +104,9 @@ onBeforeUnmount(() => {
}); });
const askAiEnabled = computed(() => { const askAiEnabled = computed(() => {
return settingsStore.isAskAiEnabled && props.language === 'javaScript'; return (
props.disableAskAi !== true && settingsStore.isAskAiEnabled && props.language === 'javaScript'
);
}); });
watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => { watch([() => props.language, () => props.mode], (_, [prevLanguage, prevMode]) => {

View File

@@ -26,6 +26,7 @@ type Props = {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
isReadOnly: false, isReadOnly: false,
targetNodeParameterContext: undefined,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -85,7 +86,7 @@ onMounted(() => {
focus(); focus();
}); });
defineExpose({ editor }); defineExpose({ editor, focus });
</script> </script>
<template> <template>

View File

@@ -2,23 +2,53 @@
import { useFocusPanelStore } from '@/stores/focusPanel.store'; import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { N8nText, N8nInput } from '@n8n/design-system'; import { N8nText, N8nInput } from '@n8n/design-system';
import { computed } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import {
formatAsExpression,
getParameterTypeOption,
isValidParameterOption,
parseFromExpression,
} from '@/utils/nodeSettingsUtils';
import { isValueExpression } from '@/utils/nodeTypesUtils'; import { isValueExpression } from '@/utils/nodeTypesUtils';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters'; import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { useResolvedExpression } from '@/composables/useResolvedExpression';
import {
AI_TRANSFORM_NODE_TYPE,
type CodeExecutionMode,
type CodeNodeEditorLanguage,
type EditorType,
HTML_NODE_TYPE,
isResourceLocatorValue,
} from 'n8n-workflow';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import { useDebounce } from '@/composables/useDebounce';
import { htmlEditorEventBus } from '@/event-bus';
import { hasFocusOnInput, isFocusableEl } from '@/utils/typesUtils';
import type { TargetNodeParameterContext } from '@/Interface';
defineOptions({ name: 'FocusPanel' }); defineOptions({ name: 'FocusPanel' });
const props = defineProps<{ const props = defineProps<{
executable: boolean; isCanvasReadOnly: boolean;
}>(); }>();
const emit = defineEmits<{
focus: [];
}>();
// ESLint: false positive
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
const inputField = ref<InstanceType<typeof N8nInput> | HTMLElement>();
const locale = useI18n(); const locale = useI18n();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const focusPanelStore = useFocusPanelStore(); const focusPanelStore = useFocusPanelStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const nodeSettingsParameters = useNodeSettingsParameters(); const nodeSettingsParameters = useNodeSettingsParameters();
const environmentsStore = useEnvironmentsStore();
const { debounce } = useDebounce();
const focusedNodeParameter = computed(() => focusPanelStore.focusedNodeParameters[0]); const focusedNodeParameter = computed(() => focusPanelStore.focusedNodeParameters[0]);
const resolvedParameter = computed(() => const resolvedParameter = computed(() =>
@@ -29,32 +59,119 @@ const resolvedParameter = computed(() =>
const focusPanelActive = computed(() => focusPanelStore.focusPanelActive); const focusPanelActive = computed(() => focusPanelStore.focusPanelActive);
const isDisabled = computed(() => {
if (!resolvedParameter.value) return false;
// shouldDisplayNodeParameter returns true if disabledOptions exists and matches, OR if disabledOptions doesn't exist
return (
!!resolvedParameter.value.parameter.disabledOptions &&
nodeSettingsParameters.shouldDisplayNodeParameter(
resolvedParameter.value.node.parameters,
resolvedParameter.value.node,
resolvedParameter.value.parameter,
'',
'disabledOptions',
)
);
});
const isDisplayed = computed(() => {
if (!resolvedParameter.value) return true;
return nodeSettingsParameters.shouldDisplayNodeParameter(
resolvedParameter.value.node.parameters,
resolvedParameter.value.node,
resolvedParameter.value.parameter,
'',
'displayOptions',
);
});
const isExecutable = computed(() => { const isExecutable = computed(() => {
if (!resolvedParameter.value) return false; if (!resolvedParameter.value) return false;
if (!isDisplayed.value) return false;
const foreignCredentials = nodeHelpers.getForeignCredentialsIfSharingEnabled( const foreignCredentials = nodeHelpers.getForeignCredentialsIfSharingEnabled(
resolvedParameter.value.node.credentials, resolvedParameter.value.node.credentials,
); );
return nodeHelpers.isNodeExecutable( return nodeHelpers.isNodeExecutable(
resolvedParameter.value.node, resolvedParameter.value.node,
props.executable, !props.isCanvasReadOnly,
foreignCredentials, foreignCredentials,
); );
}); });
function getTypeOption<T>(optionName: string): T | undefined {
return resolvedParameter.value
? getParameterTypeOption<T>(resolvedParameter.value.parameter, optionName)
: undefined;
}
const codeEditorMode = computed<CodeExecutionMode>(() => {
return resolvedParameter.value?.node.parameters.mode as CodeExecutionMode;
});
const editorType = computed<EditorType | 'json' | 'code' | 'cssEditor' | undefined>(() => {
return getTypeOption('editor') ?? undefined;
});
const editorLanguage = computed<CodeNodeEditorLanguage>(() => {
if (editorType.value === 'json' || resolvedParameter.value?.parameter.type === 'json')
return 'json' as CodeNodeEditorLanguage;
return getTypeOption('editorLanguage') ?? 'javaScript';
});
const editorRows = computed(() => getTypeOption<number>('rows'));
const isToolNode = computed(() => const isToolNode = computed(() =>
resolvedParameter.value ? nodeTypesStore.isToolNode(resolvedParameter.value?.node.type) : false, resolvedParameter.value ? nodeTypesStore.isToolNode(resolvedParameter.value?.node.type) : false,
); );
const isHtmlNode = computed(
() => !!resolvedParameter.value && resolvedParameter.value.node.type === HTML_NODE_TYPE,
);
const expressionModeEnabled = computed( const expressionModeEnabled = computed(
() => () =>
resolvedParameter.value && resolvedParameter.value &&
isValueExpression(resolvedParameter.value.parameter, resolvedParameter.value.value), isValueExpression(resolvedParameter.value.parameter, resolvedParameter.value.value),
); );
function optionSelected() { const expression = computed(() => {
// TODO: Handle the option selected (command: string) from the dropdown if (!expressionModeEnabled.value) return '';
} return isResourceLocatorValue(resolvedParameter.value)
? resolvedParameter.value.value
: resolvedParameter.value;
});
const shouldCaptureForPosthog = computed(
() => resolvedParameter.value?.node.type === AI_TRANSFORM_NODE_TYPE,
);
const isReadOnly = computed(() => props.isCanvasReadOnly || isDisabled.value);
const resolvedAdditionalExpressionData = computed(() => {
return {
$vars: environmentsStore.variablesAsObject,
};
});
const targetNodeParameterContext = computed<TargetNodeParameterContext | undefined>(() => {
if (!resolvedParameter.value) return undefined;
return {
nodeName: resolvedParameter.value.node.name,
parameterPath: resolvedParameter.value.parameterPath,
};
});
const { resolvedExpression } = useResolvedExpression({
expression,
additionalData: resolvedAdditionalExpressionData,
stringifyObject:
resolvedParameter.value && resolvedParameter.value.parameter.type !== 'multiOptions',
});
function valueChanged(value: string) { function valueChanged(value: string) {
if (resolvedParameter.value === undefined) { if (resolvedParameter.value === undefined) {
@@ -68,6 +185,65 @@ function valueChanged(value: string) {
isToolNode.value, isToolNode.value,
); );
} }
async function setFocus() {
await nextTick();
if (inputField.value) {
if (hasFocusOnInput(inputField.value)) {
inputField.value.focusOnInput();
} else if (isFocusableEl(inputField.value)) {
inputField.value.focus();
}
}
emit('focus');
}
function optionSelected(command: string) {
if (!resolvedParameter.value) return;
switch (command) {
case 'resetValue':
return (
typeof resolvedParameter.value.parameter.default === 'string' &&
valueChanged(resolvedParameter.value.parameter.default)
);
case 'addExpression': {
const newValue = formatAsExpression(
resolvedParameter.value.value,
resolvedParameter.value.parameter.type,
);
valueChanged(typeof newValue === 'string' ? newValue : newValue.value);
void setFocus();
break;
}
case 'removeExpression': {
const newValue = parseFromExpression(
resolvedParameter.value.value,
resolvedExpression.value,
resolvedParameter.value.parameter.type,
resolvedParameter.value.parameter.default,
(resolvedParameter.value.parameter.options ?? []).filter(isValidParameterOption),
);
if (typeof newValue === 'string') {
valueChanged(newValue);
} else if (newValue && typeof (newValue as { value?: unknown }).value === 'string') {
valueChanged((newValue as { value: string }).value);
}
void setFocus();
break;
}
case 'formatHtml':
htmlEditorEventBus.emit('format-html');
return;
}
}
const valueChangedDebounced = debounce(valueChanged, { debounceTime: 0 });
</script> </script>
<template> <template>
@@ -104,34 +280,96 @@ function valueChanged(value: string) {
<div :class="$style.parameterOptionsWrapper"> <div :class="$style.parameterOptionsWrapper">
<div></div> <div></div>
<ParameterOptions <ParameterOptions
v-if="isDisplayed"
:parameter="resolvedParameter.parameter" :parameter="resolvedParameter.parameter"
:value="resolvedParameter.value" :value="resolvedParameter.value"
:is-read-only="false" :is-read-only="isReadOnly"
@update:model-value="optionSelected" @update:model-value="optionSelected"
/> />
</div> </div>
<div v-if="typeof resolvedParameter.value === 'string'" :class="$style.editorContainer"> <div v-if="typeof resolvedParameter.value === 'string'" :class="$style.editorContainer">
<div v-if="!isDisplayed" :class="[$style.content, $style.emptyContent]">
<div :class="$style.emptyText">
<N8nText color="text-base">
{{ locale.baseText('nodeView.focusPanel.missingParameter') }}
</N8nText>
</div>
</div>
<ExpressionEditorModalInput <ExpressionEditorModalInput
v-if="expressionModeEnabled" v-else-if="expressionModeEnabled"
ref="inputField"
:model-value="resolvedParameter.value" :model-value="resolvedParameter.value"
:class="$style.editor" :class="$style.editor"
:is-read-only="false" :is-read-only="isReadOnly"
:path="resolvedParameter.parameterPath" :path="resolvedParameter.parameterPath"
data-test-id="expression-modal-input" data-test-id="expression-modal-input"
:target-node-parameter-context="{ :target-node-parameter-context="targetNodeParameterContext"
nodeName: resolvedParameter.node.name, @change="valueChangedDebounced($event.value)"
parameterPath: resolvedParameter.parameterPath,
}"
@change="valueChanged($event.value)"
/> />
<N8nInput <template v-else-if="['json', 'string'].includes(resolvedParameter.parameter.type)">
v-else <CodeNodeEditor
:model-value="resolvedParameter.value" v-if="editorType === 'codeNodeEditor'"
:class="$style.editor" :id="resolvedParameter.parameterPath"
type="textarea" :mode="codeEditorMode"
resize="none" :model-value="resolvedParameter.value"
@update:model-value="valueChanged($event)" :default-value="resolvedParameter.parameter.default"
></N8nInput> :language="editorLanguage"
:is-read-only="isReadOnly"
:target-node-parameter-context="targetNodeParameterContext"
fill-parent
:disable-ask-ai="true"
@update:model-value="valueChangedDebounced" />
<HtmlEditor
v-else-if="editorType === 'htmlEditor'"
:model-value="resolvedParameter.value"
:is-read-only="isReadOnly"
:rows="editorRows"
:disable-expression-coloring="!isHtmlNode"
:disable-expression-completions="!isHtmlNode"
fullscreen
@update:model-value="valueChangedDebounced" />
<CssEditor
v-else-if="editorType === 'cssEditor'"
:model-value="resolvedParameter.value"
:is-read-only="isReadOnly"
:rows="editorRows"
fullscreen
@update:model-value="valueChangedDebounced" />
<SqlEditor
v-else-if="editorType === 'sqlEditor'"
:model-value="resolvedParameter.value"
:dialect="getTypeOption('sqlDialect')"
:is-read-only="isReadOnly"
:rows="editorRows"
fullscreen
@update:model-value="valueChangedDebounced" />
<JsEditor
v-else-if="editorType === 'jsEditor'"
:model-value="resolvedParameter.value"
:is-read-only="isReadOnly"
:rows="editorRows"
:posthog-capture="shouldCaptureForPosthog"
fill-parent
@update:model-value="valueChangedDebounced" />
<JsonEditor
v-else-if="resolvedParameter.parameter.type === 'json'"
:model-value="resolvedParameter.value"
:is-read-only="isReadOnly"
:rows="editorRows"
fullscreen
fill-parent
@update:model-value="valueChangedDebounced" />
<N8nInput
v-else
ref="inputField"
:model-value="resolvedParameter.value"
:class="$style.editor"
:readonly="isReadOnly"
type="textarea"
resize="none"
@update:model-value="valueChangedDebounced"
></N8nInput
></template>
</div> </div>
</div> </div>
</div> </div>
@@ -151,7 +389,7 @@ function valueChanged(value: string) {
flex-direction: column; flex-direction: column;
width: 528px; width: 528px;
border-left: 1px solid var(--color-foreground-base); border-left: 1px solid var(--color-foreground-base);
background: var(--color-background-base); background: var(--color-foreground-light);
overflow-y: hidden; overflow-y: hidden;
} }
@@ -164,7 +402,7 @@ function valueChanged(value: string) {
padding: var(--spacing-2xs); padding: var(--spacing-2xs);
justify-content: space-between; justify-content: space-between;
border-bottom: 1px solid var(--color-foreground-base); border-bottom: 1px solid var(--color-foreground-base);
background: var(--color-background-xlight); background: var(--color-foreground-xlight);
} }
.content { .content {
@@ -193,7 +431,7 @@ function valueChanged(value: string) {
.tabHeaderText { .tabHeaderText {
display: flex; display: flex;
gap: var(--spacing-4xs); gap: var(--spacing-4xs);
align-items: center; align-items: baseline;
} }
.buttonWrapper { .buttonWrapper {
@@ -216,7 +454,6 @@ function valueChanged(value: string) {
} }
.editorContainer { .editorContainer {
display: flex;
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
@@ -224,7 +461,7 @@ function valueChanged(value: string) {
display: flex; display: flex;
height: 100%; height: 100%;
width: 100%; width: 100%;
font-size: var(--font-size-xs); font-size: var(--font-size-2xs);
:global(.cm-editor) { :global(.cm-editor) {
width: 100%; width: 100%;

View File

@@ -3,10 +3,9 @@ import type {
CalloutActionType, CalloutActionType,
INodeParameters, INodeParameters,
INodeProperties, INodeProperties,
NodeParameterValue,
NodeParameterValueType, NodeParameterValueType,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ADD_FORM_NOTICE, deepCopy, getParameterValueByPath, NodeHelpers } from 'n8n-workflow'; import { ADD_FORM_NOTICE, getParameterValueByPath, NodeHelpers } from 'n8n-workflow';
import { computed, defineAsyncComponent, onErrorCaptured, ref, watch, type WatchSource } from 'vue'; import { computed, defineAsyncComponent, onErrorCaptured, ref, watch, type WatchSource } from 'vue';
import type { INodeUi, IUpdateInformation } from '@/Interface'; import type { INodeUi, IUpdateInformation } from '@/Interface';
@@ -19,7 +18,7 @@ import MultipleParameter from '@/components/MultipleParameter.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue'; import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue'; import ResourceMapper from '@/components/ResourceMapper/ResourceMapper.vue';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeSettingsParameters } from '@/composables/useNodeSettingsParameters';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
import { import {
@@ -32,15 +31,9 @@ import {
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import {
getMainAuthField,
getNodeAuthFields,
isAuthRelatedParameter,
} from '@/utils/nodeTypesUtils';
import { captureException } from '@sentry/vue'; import { captureException } from '@sentry/vue';
import { computedWithControl } from '@vueuse/core'; import { computedWithControl } from '@vueuse/core';
import get from 'lodash/get'; import get from 'lodash/get';
import set from 'lodash/set';
import { import {
N8nCallout, N8nCallout,
N8nIcon, N8nIcon,
@@ -52,6 +45,7 @@ import {
} from '@n8n/design-system'; } from '@n8n/design-system';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useCalloutHelpers } from '@/composables/useCalloutHelpers'; import { useCalloutHelpers } from '@/composables/useCalloutHelpers';
import { getParameterTypeOption } from '@/utils/nodeSettingsUtils';
const LazyFixedCollectionParameter = defineAsyncComponent( const LazyFixedCollectionParameter = defineAsyncComponent(
async () => await import('./FixedCollectionParameter.vue'), async () => await import('./FixedCollectionParameter.vue'),
@@ -86,7 +80,7 @@ const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const message = useMessage(); const message = useMessage();
const nodeHelpers = useNodeHelpers(); const nodeSettingsParameters = useNodeSettingsParameters();
const asyncLoadingError = ref(false); const asyncLoadingError = ref(false);
const workflowHelpers = useWorkflowHelpers(); const workflowHelpers = useWorkflowHelpers();
const i18n = useI18n(); const i18n = useI18n();
@@ -114,6 +108,8 @@ onErrorCaptured((e, component) => {
return false; return false;
}); });
const node = computed(() => props.node ?? ndvStore.activeNode);
const nodeType = computed(() => { const nodeType = computed(() => {
if (node.value) { if (node.value) {
return nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion); return nodeTypesStore.getNodeType(node.value.type, node.value.typeVersion);
@@ -121,13 +117,11 @@ const nodeType = computed(() => {
return null; return null;
}); });
const node = computed(() => props.node ?? ndvStore.activeNode);
const filteredParameters = computedWithControl( const filteredParameters = computedWithControl(
[() => props.parameters, () => props.nodeValues] as WatchSource[], [() => props.parameters, () => props.nodeValues] as WatchSource[],
() => { () => {
const parameters = props.parameters.filter((parameter: INodeProperties) => const parameters = props.parameters.filter((parameter: INodeProperties) =>
displayNodeParameter(parameter), shouldDisplayNodeParameter(parameter),
); );
if (node.value && node.value.type === FORM_TRIGGER_NODE_TYPE) { if (node.value && node.value.type === FORM_TRIGGER_NODE_TYPE) {
@@ -154,10 +148,6 @@ const filteredParameterNames = computed(() => {
return filteredParameters.value.map((parameter) => parameter.name); return filteredParameters.value.map((parameter) => parameter.name);
}); });
const nodeAuthFields = computed(() => {
return getNodeAuthFields(nodeType.value);
});
const credentialsParameterIndex = computed(() => { const credentialsParameterIndex = computed(() => {
return filteredParameters.value.findIndex((parameter) => parameter.type === 'credentials'); return filteredParameters.value.findIndex((parameter) => parameter.type === 'credentials');
}); });
@@ -182,10 +172,6 @@ const indexToShowSlotAt = computed(() => {
return Math.min(index, filteredParameters.value.length - 1); return Math.min(index, filteredParameters.value.length - 1);
}); });
const mainNodeAuthField = computed(() => {
return getMainAuthField(nodeType.value || null);
});
watch(filteredParameterNames, (newValue, oldValue) => { watch(filteredParameterNames, (newValue, oldValue) => {
if (newValue === undefined) { if (newValue === undefined) {
return; return;
@@ -210,8 +196,8 @@ function updateFormTriggerParameters(parameters: INodeProperties[], triggerName:
const connectedNodes = workflow.getChildNodes(triggerName); const connectedNodes = workflow.getChildNodes(triggerName);
const hasFormPage = connectedNodes.some((nodeName) => { const hasFormPage = connectedNodes.some((nodeName) => {
const node = workflow.getNode(nodeName); const _node = workflow.getNode(nodeName);
return node && node.type === FORM_NODE_TYPE; return _node && _node.type === FORM_NODE_TYPE;
}); });
if (hasFormPage) { if (hasFormPage) {
@@ -255,15 +241,15 @@ function updateWaitParameters(parameters: INodeProperties[], nodeName: string) {
const parentNodes = workflow.getParentNodes(nodeName); const parentNodes = workflow.getParentNodes(nodeName);
const formTriggerName = parentNodes.find( const formTriggerName = parentNodes.find(
(node) => workflow.nodes[node].type === FORM_TRIGGER_NODE_TYPE, (_node) => workflow.nodes[_node].type === FORM_TRIGGER_NODE_TYPE,
); );
if (!formTriggerName) return parameters; if (!formTriggerName) return parameters;
const connectedNodes = workflow.getChildNodes(formTriggerName); const connectedNodes = workflow.getChildNodes(formTriggerName);
const hasFormPage = connectedNodes.some((nodeName) => { const hasFormPage = connectedNodes.some((_nodeName) => {
const node = workflow.getNode(nodeName); const _node = workflow.getNode(_nodeName);
return node && node.type === FORM_NODE_TYPE; return _node && _node.type === FORM_NODE_TYPE;
}); });
if (hasFormPage) { if (hasFormPage) {
@@ -294,7 +280,7 @@ function updateFormParameters(parameters: INodeProperties[], nodeName: string) {
const parentNodes = workflow.getParentNodes(nodeName); const parentNodes = workflow.getParentNodes(nodeName);
const formTriggerName = parentNodes.find( const formTriggerName = parentNodes.find(
(node) => workflow.nodes[node].type === FORM_TRIGGER_NODE_TYPE, (_node) => workflow.nodes[_node].type === FORM_TRIGGER_NODE_TYPE,
); );
if (formTriggerName) return parameters.filter((parameter) => parameter.name !== 'triggerNotice'); if (formTriggerName) return parameters.filter((parameter) => parameter.name !== 'triggerNotice');
@@ -321,22 +307,7 @@ function getCredentialsDependencies() {
} }
function multipleValues(parameter: INodeProperties): boolean { function multipleValues(parameter: INodeProperties): boolean {
return getArgument('multipleValues', parameter) === true; return getParameterTypeOption(parameter, 'multipleValues') === 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 { function getPath(parameterName: string): string {
@@ -354,116 +325,15 @@ function deleteOption(optionName: string): void {
emit('valueChanged', parameterData); emit('valueChanged', parameterData);
} }
function mustHideDuringCustomApiCall( function shouldDisplayNodeParameter(
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, parameter: INodeProperties,
displayKey: 'displayOptions' | 'disabledOptions' = 'displayOptions', displayKey: 'displayOptions' | 'disabledOptions' = 'displayOptions',
): boolean { ): boolean {
if (parameter.type === 'hidden') { return nodeSettingsParameters.shouldDisplayNodeParameter(
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, props.nodeValues,
node.value,
parameter, parameter,
props.path, props.path,
node.value,
displayKey, displayKey,
); );
} }
@@ -493,26 +363,15 @@ function getParameterIssues(parameter: INodeProperties): string[] {
return issues.parameters?.[parameter.name] ?? []; 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 { function shouldShowOptions(parameter: INodeProperties): boolean {
return parameter.type !== 'resourceMapper'; return parameter.type !== 'resourceMapper';
} }
function getDependentParametersValues(parameter: INodeProperties): string | null { function getDependentParametersValues(parameter: INodeProperties): string | null {
const loadOptionsDependsOn = getArgument('loadOptionsDependsOn', parameter) as const loadOptionsDependsOn = getParameterTypeOption<string[] | undefined>(
| string[] parameter,
| undefined; 'loadOptionsDependsOn',
);
if (loadOptionsDependsOn === undefined) { if (loadOptionsDependsOn === undefined) {
return null; return null;
@@ -529,7 +388,7 @@ function getDependentParametersValues(parameter: INodeProperties): string | null
} }
return returnValues.join('|'); return returnValues.join('|');
} catch (error) { } catch {
return null; return null;
} }
} }
@@ -792,7 +651,7 @@ const onCalloutDismiss = async (parameter: INodeProperties) => {
:path="getPath(parameter.name)" :path="getPath(parameter.name)"
:is-read-only=" :is-read-only="
isReadOnly || isReadOnly ||
(parameter.disabledOptions && displayNodeParameter(parameter, 'disabledOptions')) (parameter.disabledOptions && shouldDisplayNodeParameter(parameter, 'disabledOptions'))
" "
:hide-label="false" :hide-label="false"
:node-values="nodeValues" :node-values="nodeValues"

View File

@@ -79,7 +79,7 @@ export const useCodeEditor = <L extends CodeEditorLanguage>({
language: MaybeRefOrGetter<L>; language: MaybeRefOrGetter<L>;
editorValue?: MaybeRefOrGetter<string>; editorValue?: MaybeRefOrGetter<string>;
placeholder?: MaybeRefOrGetter<string>; placeholder?: MaybeRefOrGetter<string>;
targetNodeParameterContext?: MaybeRefOrGetter<TargetNodeParameterContext>; targetNodeParameterContext?: MaybeRefOrGetter<TargetNodeParameterContext | undefined>;
extensions?: MaybeRefOrGetter<Extension[]>; extensions?: MaybeRefOrGetter<Extension[]>;
isReadOnly?: MaybeRefOrGetter<boolean>; isReadOnly?: MaybeRefOrGetter<boolean>;
theme?: MaybeRefOrGetter<{ theme?: MaybeRefOrGetter<{

View File

@@ -19,7 +19,7 @@ import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import type { TargetItem, TargetNodeParameterContext } from '@/Interface'; import type { TargetItem, TargetNodeParameterContext } from '@/Interface';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { type ResolveParameterOptions, useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { closeCursorInfoBox } from '@/plugins/codemirror/tooltips/InfoBoxTooltip'; import { closeCursorInfoBox } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions'; import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
@@ -311,9 +311,9 @@ export const useExpressionEditor = ({
// e.g. credential modal // e.g. credential modal
result.resolved = Expression.resolveWithoutWorkflow(resolvable, toValue(additionalData)); result.resolved = Expression.resolveWithoutWorkflow(resolvable, toValue(additionalData));
} else { } else {
let opts: Record<string, unknown> = { let opts: ResolveParameterOptions = {
additionalKeys: toValue(additionalData), additionalKeys: toValue(additionalData),
targetNodeParameterContext, contextNodeName: toValue(targetNodeParameterContext)?.nodeName,
}; };
if ( if (
toValue(targetNodeParameterContext) === undefined && toValue(targetNodeParameterContext) === undefined &&

View File

@@ -11,22 +11,33 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { useTelemetry } from './useTelemetry'; import { useTelemetry } from './useTelemetry';
import { useNodeHelpers } from './useNodeHelpers'; import { useNodeHelpers } from './useNodeHelpers';
import { useWorkflowHelpers } from './useWorkflowHelpers';
import { useCanvasOperations } from './useCanvasOperations'; import { useCanvasOperations } from './useCanvasOperations';
import { useExternalHooks } from './useExternalHooks'; import { useExternalHooks } from './useExternalHooks';
import type { INodeUi, IUpdateInformation } from '@/Interface'; import type { INodeUi, IUpdateInformation } from '@/Interface';
import { updateDynamicConnections, updateParameterByPath } from '@/utils/nodeSettingsUtils'; import {
mustHideDuringCustomApiCall,
updateDynamicConnections,
updateParameterByPath,
} from '@/utils/nodeSettingsUtils';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useFocusPanelStore } from '@/stores/focusPanel.store'; import { useFocusPanelStore } from '@/stores/focusPanel.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { CUSTOM_API_CALL_KEY } from '@/constants'; import { CUSTOM_API_CALL_KEY, KEEP_AUTH_IN_NDV_FOR_NODES } from '@/constants';
import { omitKey } from '@/utils/objectUtils'; import { omitKey } from '@/utils/objectUtils';
import {
getMainAuthField,
getNodeAuthFields,
isAuthRelatedParameter,
} from '@/utils/nodeTypesUtils';
export function useNodeSettingsParameters() { export function useNodeSettingsParameters() {
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
const workflowHelpers = useWorkflowHelpers();
const canvasOperations = useCanvasOperations(); const canvasOperations = useCanvasOperations();
const externalHooks = useExternalHooks(); const externalHooks = useExternalHooks();
@@ -236,6 +247,113 @@ export function useNodeSettingsParameters() {
focusPanelStore.focusPanelActive = true; focusPanelStore.focusPanelActive = true;
} }
function shouldDisplayNodeParameter(
nodeParameters: INodeParameters,
node: INodeUi | null,
parameter: INodeProperties,
path: string | undefined = '',
displayKey: 'displayOptions' | 'disabledOptions' = 'displayOptions',
): boolean {
if (parameter.type === 'hidden') {
return false;
}
if (
nodeHelpers.isCustomApiCallSelected(nodeParameters) &&
mustHideDuringCustomApiCall(parameter, nodeParameters)
) {
return false;
}
const nodeType = !node ? null : nodeTypesStore.getNodeType(node.type, node.typeVersion);
// 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
const shouldHideAuthRelatedParameter = isAuthRelatedParameter(
getNodeAuthFields(nodeType),
parameter,
);
const mainNodeAuthField = getMainAuthField(nodeType);
// Hide authentication related fields since it will now be part of credentials modal
if (
!KEEP_AUTH_IN_NDV_FOR_NODES.includes(node?.type ?? '') &&
mainNodeAuthField &&
(parameter.name === mainNodeAuthField.name || shouldHideAuthRelatedParameter)
) {
return false;
}
if (parameter[displayKey] === undefined) {
// If it is not defined no need to do a proper check
return true;
}
const nodeParams: INodeParameters = {};
let rawValues = nodeParameters;
if (path) {
rawValues = get(nodeParameters, 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 {
nodeParams[key] = workflowHelpers.resolveExpression(
value,
nodeParams,
) as NodeParameterValue;
} catch (e) {
// If expression is invalid ignore
nodeParams[key] = '';
}
parameterGotResolved = true;
}
} else {
// Does not contain an expression, add directly
nodeParams[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 (path) {
rawValues = deepCopy(nodeParameters);
set(rawValues, path, nodeParams);
return nodeHelpers.displayParameter(rawValues, parameter, path, node, displayKey);
} else {
return nodeHelpers.displayParameter(nodeParams, parameter, '', node, displayKey);
}
}
return nodeHelpers.displayParameter(nodeParameters, parameter, path, node, displayKey);
}
function shouldSkipParamValidation(value: string | number | boolean | null) { function shouldSkipParamValidation(value: string | number | boolean | null) {
return typeof value === 'string' && value.includes(CUSTOM_API_CALL_KEY); return typeof value === 'string' && value.includes(CUSTOM_API_CALL_KEY);
} }
@@ -243,6 +361,7 @@ export function useNodeSettingsParameters() {
return { return {
nodeValues, nodeValues,
setValue, setValue,
shouldDisplayNodeParameter,
updateParameterByPath, updateParameterByPath,
updateNodeParameter, updateNodeParameter,
handleFocus, handleFocus,

View File

@@ -126,18 +126,20 @@ export function dollarOptions(context: CompletionContext): Completion[] {
if (receivesNoBinaryData(targetNodeParameterContext?.nodeName)) SKIP.add('$binary'); if (receivesNoBinaryData(targetNodeParameterContext?.nodeName)) SKIP.add('$binary');
const previousNodesCompletions = autocompletableNodeNames().map((nodeName) => { const previousNodesCompletions = autocompletableNodeNames(targetNodeParameterContext).map(
const label = `$('${escapeMappingString(nodeName)}')`; (nodeName) => {
return { const label = `$('${escapeMappingString(nodeName)}')`;
label, return {
info: createInfoBoxRenderer({ label,
name: label, info: createInfoBoxRenderer({
returnType: 'Object', name: label,
description: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }), returnType: 'Object',
}), description: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
section: PREVIOUS_NODES_SECTION, }),
}; section: PREVIOUS_NODES_SECTION,
}); };
},
);
return recommendedCompletions return recommendedCompletions
.concat(ROOT_DOLLAR_COMPLETIONS) .concat(ROOT_DOLLAR_COMPLETIONS)

View File

@@ -215,11 +215,11 @@ export const hasActiveNode = (targetNodeParameterContext?: TargetNodeParameterCo
export const isSplitInBatchesAbsent = () => export const isSplitInBatchesAbsent = () =>
!useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE); !useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE);
export function autocompletableNodeNames(contextNodeName?: string) { export function autocompletableNodeNames(targetNodeParameterContext?: TargetNodeParameterContext) {
const activeNode = const activeNode =
contextNodeName === undefined targetNodeParameterContext === undefined
? useNDVStore().activeNode ? useNDVStore().activeNode
: useWorkflowsStore().getNodeByName(contextNodeName); : useWorkflowsStore().getNodeByName(targetNodeParameterContext.nodeName);
if (!activeNode) return []; if (!activeNode) return [];

View File

@@ -13,6 +13,7 @@ import {
} from '../../completions/utils'; } from '../../completions/utils';
import { typescriptWorkerFacet } from './facet'; import { typescriptWorkerFacet } from './facet';
import { blockCommentSnippet, snippets } from './snippets'; import { blockCommentSnippet, snippets } from './snippets';
import { TARGET_NODE_PARAMETER_FACET } from '../../completions/constants';
const START_CHARACTERS = ['"', "'", '(', '.', '@']; const START_CHARACTERS = ['"', "'", '(', '.', '@'];
const START_CHARACTERS_REGEX = /[\.\(\'\"\@]/; const START_CHARACTERS_REGEX = /[\.\(\'\"\@]/;
@@ -30,7 +31,7 @@ export const matchText = (context: CompletionContext) => {
export const typescriptCompletionSource: CompletionSource = async (context) => { export const typescriptCompletionSource: CompletionSource = async (context) => {
const { worker } = context.state.facet(typescriptWorkerFacet); const { worker } = context.state.facet(typescriptWorkerFacet);
const targetNodeParameter = context.state.facet(TARGET_NODE_PARAMETER_FACET);
const word = matchText(context); const word = matchText(context);
const blockComment = context.matchBefore(/\/\*?\*?/); const blockComment = context.matchBefore(/\/\*?\*?/);
@@ -55,7 +56,7 @@ export const typescriptCompletionSource: CompletionSource = async (context) => {
if (opt.label === '$()') { if (opt.label === '$()') {
return [ return [
opt, opt,
...autocompletableNodeNames().map((name) => ({ ...autocompletableNodeNames(targetNodeParameter).map((name) => ({
...opt, ...opt,
label: `$('${escapeMappingString(name)}')`, label: `$('${escapeMappingString(name)}')`,
})), })),

View File

@@ -28,7 +28,7 @@ export function useTypescript(
view: MaybeRefOrGetter<EditorView | undefined>, view: MaybeRefOrGetter<EditorView | undefined>,
mode: MaybeRefOrGetter<CodeExecutionMode>, mode: MaybeRefOrGetter<CodeExecutionMode>,
id: MaybeRefOrGetter<string>, id: MaybeRefOrGetter<string>,
targetNodeParameterContext?: MaybeRefOrGetter<TargetNodeParameterContext>, targetNodeParameterContext?: MaybeRefOrGetter<TargetNodeParameterContext | undefined>,
) { ) {
const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema(); const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
@@ -47,7 +47,7 @@ export function useTypescript(
{ {
id: toValue(id), id: toValue(id),
content: Comlink.proxy((toValue(view)?.state.doc ?? Text.empty).toJSON()), content: Comlink.proxy((toValue(view)?.state.doc ?? Text.empty).toJSON()),
allNodeNames: autocompletableNodeNames(), allNodeNames: autocompletableNodeNames(toValue(targetNodeParameterContext)),
variables: useEnvironmentsStore().variables.map((v) => v.key), variables: useEnvironmentsStore().variables.map((v) => v.key),
inputNodeNames: activeNodeName inputNodeNames: activeNodeName
? workflowsStore ? workflowsStore

View File

@@ -259,6 +259,22 @@ export function isValidParameterOption(
return 'value' in option && isPresent(option.value) && isPresent(option.name); return 'value' in option && isPresent(option.value) && isPresent(option.name);
} }
export function mustHideDuringCustomApiCall(
parameter: INodeProperties,
nodeParameters: INodeParameters,
): boolean {
if (parameter?.displayOptions?.hide) return true;
const MUST_REMAIN_VISIBLE = [
'authentication',
'resource',
'operation',
...Object.keys(nodeParameters),
];
return !MUST_REMAIN_VISIBLE.includes(parameter.name);
}
export function nameIsParameter( export function nameIsParameter(
parameterData: IUpdateInformation, parameterData: IUpdateInformation,
): parameterData is IUpdateInformation & { name: `parameters.${string}` } { ): parameterData is IUpdateInformation & { name: `parameters.${string}` } {

View File

@@ -2157,7 +2157,7 @@ onBeforeUnmount(() => {
/> />
</Suspense> </Suspense>
</WorkflowCanvas> </WorkflowCanvas>
<FocusPanel v-if="isFocusPanelFeatureEnabled" :executable="!isCanvasReadOnly" /> <FocusPanel v-if="isFocusPanelFeatureEnabled" :is-canvas-read-only="isCanvasReadOnly" />
</div> </div>
</template> </template>