feat(editor): Propagate targetNodeParameterContext throughout expression resolution logic (no-changelog) (#16476)

This commit is contained in:
Charlie Kolb
2025-06-18 17:05:32 +02:00
committed by GitHub
parent 32b42dd2f6
commit 701c31cfbc
11 changed files with 99 additions and 36 deletions

View File

@@ -1058,6 +1058,11 @@ export interface NDVState {
highlightDraggables: boolean;
}
export type TargetNodeParameterContext = {
nodeName: string;
parameterPath: string;
};
export interface NotificationOptions extends Partial<ElementNotificationOptions> {
message: string | ElementNotificationOptions['message'];
}

View File

@@ -15,10 +15,12 @@ import { removeExpressionPrefix } from '@/utils/expressions';
import { mappingDropCursor } from '@/plugins/codemirror/dragAndDrop';
import { editorKeymap } from '@/plugins/codemirror/keymap';
import { expressionCloseBrackets } from '@/plugins/codemirror/expressionCloseBrackets';
import type { TargetNodeParameterContext } from '@/Interface';
type Props = {
modelValue: string;
path: string;
targetNodeParameterContext?: TargetNodeParameterContext;
isReadOnly?: boolean;
};
@@ -52,7 +54,11 @@ const { segments, readEditorValue, editor, hasFocus, focus } = useExpressionEdit
editorValue,
extensions,
isReadOnly: computed(() => props.isReadOnly),
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
autocompleteTelemetry: {
enabled: true,
parameterPath: props.path,
},
targetNodeParameterContext: props.targetNodeParameterContext,
});
watch(

View File

@@ -53,6 +53,7 @@ import { mappingDropCursor } from '../plugins/codemirror/dragAndDrop';
import { languageFacet, type CodeEditorLanguage } from '../plugins/codemirror/format';
import debounce from 'lodash/debounce';
import { ignoreUpdateAnnotation } from '../utils/forceParse';
import type { TargetNodeParameterContext } from '@/Interface';
export type CodeEditorLanguageParamsMap = {
json: {};
@@ -67,6 +68,7 @@ export const useCodeEditor = <L extends CodeEditorLanguage>({
language,
languageParams,
placeholder,
targetNodeParameterContext = undefined,
extensions = [],
isReadOnly = false,
theme = {},
@@ -77,6 +79,7 @@ export const useCodeEditor = <L extends CodeEditorLanguage>({
language: MaybeRefOrGetter<L>;
editorValue?: MaybeRefOrGetter<string>;
placeholder?: MaybeRefOrGetter<string>;
targetNodeParameterContext?: MaybeRefOrGetter<TargetNodeParameterContext>;
extensions?: MaybeRefOrGetter<Extension[]>;
isReadOnly?: MaybeRefOrGetter<boolean>;
theme?: MaybeRefOrGetter<{
@@ -106,7 +109,12 @@ export const useCodeEditor = <L extends CodeEditorLanguage>({
const params = toValue(languageParams);
return params && 'mode' in params ? params.mode : 'runOnceForAllItems';
});
const { createWorker: createTsWorker } = useTypescript(editor, mode, id);
const { createWorker: createTsWorker } = useTypescript(
editor,
mode,
id,
targetNodeParameterContext,
);
function getInitialLanguageExtensions(lang: CodeEditorLanguage): Extension[] {
switch (lang) {

View File

@@ -18,7 +18,7 @@ import { Expression, ExpressionExtensions } from 'n8n-workflow';
import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import type { TargetItem } from '@/Interface';
import type { TargetItem, TargetNodeParameterContext } from '@/Interface';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { closeCursorInfoBox } from '@/plugins/codemirror/tooltips/InfoBoxTooltip';
@@ -43,6 +43,7 @@ import { ignoreUpdateAnnotation } from '../utils/forceParse';
export const useExpressionEditor = ({
editorRef,
editorValue,
targetNodeParameterContext,
extensions = [],
additionalData = {},
skipSegments = [],
@@ -52,6 +53,7 @@ export const useExpressionEditor = ({
}: {
editorRef: MaybeRefOrGetter<HTMLElement | undefined>;
editorValue?: MaybeRefOrGetter<string>;
targetNodeParameterContext?: MaybeRefOrGetter<TargetNodeParameterContext>;
extensions?: MaybeRefOrGetter<Extension[]>;
additionalData?: MaybeRefOrGetter<IDataObject>;
skipSegments?: MaybeRefOrGetter<string[]>;
@@ -305,12 +307,18 @@ export const useExpressionEditor = ({
};
try {
if (!ndvStore.activeNode) {
if (!ndvStore.activeNode && toValue(targetNodeParameterContext) === undefined) {
// e.g. credential modal
result.resolved = Expression.resolveWithoutWorkflow(resolvable, toValue(additionalData));
} else {
let opts: Record<string, unknown> = { additionalKeys: toValue(additionalData) };
if (ndvStore.isInputParentOfActiveNode) {
let opts: Record<string, unknown> = {
additionalKeys: toValue(additionalData),
targetNodeParameterContext,
};
if (
toValue(targetNodeParameterContext) === undefined &&
ndvStore.isInputParentOfActiveNode
) {
opts = {
targetItem: target ?? undefined,
inputNodeName: ndvStore.ndvInputNodeName,

View File

@@ -12,11 +12,13 @@ export function useResolvedExpression({
additionalData,
isForCredential,
stringifyObject,
contextNodeName,
}: {
expression: MaybeRefOrGetter<unknown>;
additionalData?: MaybeRefOrGetter<IDataObject>;
isForCredential?: MaybeRefOrGetter<boolean>;
stringifyObject?: MaybeRefOrGetter<boolean>;
contextNodeName?: MaybeRefOrGetter<string>;
}) {
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
@@ -45,9 +47,10 @@ export function useResolvedExpression({
let options: ResolveParameterOptions = {
isForCredential: toValue(isForCredential),
additionalKeys: toValue(additionalData),
contextNodeName: toValue(contextNodeName),
};
if (ndvStore.isInputParentOfActiveNode) {
if (contextNodeName === undefined && ndvStore.isInputParentOfActiveNode) {
options = {
...options,
targetItem: targetItem.value ?? undefined,

View File

@@ -18,7 +18,7 @@ export function blankCompletions(context: CompletionContext): CompletionResult |
return {
from: word.to,
options: dollarOptions().map(stripExcessParens(context)),
options: dollarOptions(context).map(stripExcessParens(context)),
filter: false,
};
}

View File

@@ -3,7 +3,6 @@ import { setActivePinia } from 'pinia';
import { DateTime } from 'luxon';
import * as workflowHelpers from '@/composables/useWorkflowHelpers';
import { dollarOptions } from '@/plugins/codemirror/completions/dollar.completions';
import * as utils from '@/plugins/codemirror/completions/utils';
import {
extensions,
@@ -62,7 +61,7 @@ describe('No completions', () => {
describe('Top-level completions', () => {
test('should return dollar completions for blank position: {{ | }}', () => {
const result = completions('{{ | }}');
expect(result).toHaveLength(dollarOptions().length);
expect(result).toHaveLength(18);
expect(result?.[0]).toEqual(
expect.objectContaining({
@@ -109,7 +108,7 @@ describe('Top-level completions', () => {
});
test('should return dollar completions for: {{ $| }}', () => {
expect(completions('{{ $| }}')).toHaveLength(dollarOptions().length);
expect(completions('{{ $| }}')).toHaveLength(18);
});
test('should return node selector completions for: {{ $(| }}', () => {

View File

@@ -2,6 +2,8 @@ import type { Completion, CompletionSection } from '@codemirror/autocomplete';
import { i18n } from '@n8n/i18n';
import { withSectionHeader } from './utils';
import { createInfoBoxRenderer } from './infoBoxRenderer';
import { Facet } from '@codemirror/state';
import type { TargetNodeParameterContext } from '@/Interface';
export const FIELDS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.fields'),
@@ -436,3 +438,10 @@ export const STRING_SECTIONS: Record<string, CompletionSection> = {
rank: 5,
}),
};
export const TARGET_NODE_PARAMETER_FACET = Facet.define<
TargetNodeParameterContext | undefined,
TargetNodeParameterContext | undefined
>({
combine: (values) => values[0],
});

View File

@@ -18,6 +18,7 @@ import {
PREVIOUS_NODES_SECTION,
RECOMMENDED_SECTION,
ROOT_DOLLAR_COMPLETIONS,
TARGET_NODE_PARAMETER_FACET,
} from './constants';
import { createInfoBoxRenderer } from './infoBoxRenderer';
@@ -31,7 +32,7 @@ export function dollarCompletions(context: CompletionContext): CompletionResult
if (word.from === word.to && !context.explicit) return null;
let options = dollarOptions().map(stripExcessParens(context));
let options = dollarOptions(context).map(stripExcessParens(context));
const userInput = word.text;
@@ -53,7 +54,7 @@ export function dollarCompletions(context: CompletionContext): CompletionResult
};
}
export function dollarOptions(): Completion[] {
export function dollarOptions(context: CompletionContext): Completion[] {
const SKIP = new Set();
let recommendedCompletions: Completion[] = [];
@@ -117,11 +118,13 @@ export function dollarOptions(): Completion[] {
: [];
}
if (!hasActiveNode()) {
const targetNodeParameterContext = context.state.facet(TARGET_NODE_PARAMETER_FACET);
if (!hasActiveNode(targetNodeParameterContext)) {
return [];
}
if (receivesNoBinaryData()) SKIP.add('$binary');
if (receivesNoBinaryData(targetNodeParameterContext?.nodeName)) SKIP.add('$binary');
const previousNodesCompletions = autocompletableNodeNames().map((nodeName) => {
const label = `$('${escapeMappingString(nodeName)}')`;

View File

@@ -19,6 +19,7 @@ import { EditorSelection, type TransactionSpec } from '@codemirror/state';
import type { SyntaxNode, Tree } from '@lezer/common';
import type { DocMetadata } from 'n8n-workflow';
import { escapeMappingString } from '@/utils/mappingUtils';
import type { TargetNodeParameterContext } from '@/Interface';
/**
* Split user input into base (to resolve) and tail (to filter).
@@ -144,19 +145,19 @@ export const isAllowedInDotNotation = (str: string) => {
// resolution-based utils
// ----------------------------------
export function receivesNoBinaryData() {
export function receivesNoBinaryData(contextNodeName?: string) {
try {
return resolveAutocompleteExpression('={{ $binary }}')?.data === undefined;
return resolveAutocompleteExpression('={{ $binary }}', contextNodeName)?.data === undefined;
} catch {
return true;
}
}
export function hasNoParams(toResolve: string) {
export function hasNoParams(toResolve: string, contextNodeName?: string) {
let params;
try {
params = resolveAutocompleteExpression(`={{ ${toResolve}.params }}`);
params = resolveAutocompleteExpression(`={{ ${toResolve}.params }}`, contextNodeName);
} catch {
return true;
}
@@ -168,19 +169,21 @@ export function hasNoParams(toResolve: string) {
return paramKeys.length === 1 && isPseudoParam(paramKeys[0]);
}
export function resolveAutocompleteExpression(expression: string) {
export function resolveAutocompleteExpression(expression: string, contextNodeName?: string) {
const ndvStore = useNDVStore();
return resolveParameter(
expression,
ndvStore.isInputParentOfActiveNode
const inputData =
contextNodeName === undefined && ndvStore.isInputParentOfActiveNode
? {
targetItem: ndvStore.expressionTargetItem ?? undefined,
inputNodeName: ndvStore.ndvInputNodeName,
inputRunIndex: ndvStore.ndvInputRunIndex,
inputBranchIndex: ndvStore.ndvInputBranchIndex,
}
: {},
);
: {};
return resolveParameter(expression, {
...inputData,
contextNodeName,
});
}
// ----------------------------------
@@ -189,21 +192,34 @@ export function resolveAutocompleteExpression(expression: string) {
export const isCredentialsModalOpen = () => useUIStore().modalsById[CREDENTIAL_EDIT_MODAL_KEY].open;
export const isInHttpNodePagination = () => {
const ndvStore = useNDVStore();
return (
ndvStore.activeNode?.type === HTTP_REQUEST_NODE_TYPE &&
ndvStore.focusedInputPath.startsWith('parameters.options.pagination')
);
export const isInHttpNodePagination = (targetNodeParameterContext?: TargetNodeParameterContext) => {
let nodeType: string | undefined;
let path: string;
if (targetNodeParameterContext) {
nodeType = targetNodeParameterContext.nodeName;
path = targetNodeParameterContext.parameterPath;
} else {
const ndvStore = useNDVStore();
nodeType = ndvStore.activeNode?.type;
path = ndvStore.focusedInputPath;
}
return nodeType === HTTP_REQUEST_NODE_TYPE && path.startsWith('parameters.options.pagination');
};
export const hasActiveNode = () => useNDVStore().activeNode?.name !== undefined;
export const hasActiveNode = (targetNodeParameterContext?: TargetNodeParameterContext) =>
(targetNodeParameterContext !== undefined &&
useWorkflowsStore().getNodeByName(targetNodeParameterContext.nodeName) !== null) ||
useNDVStore().activeNode?.name !== undefined;
export const isSplitInBatchesAbsent = () =>
!useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE);
export function autocompletableNodeNames() {
const activeNode = useNDVStore().activeNode;
export function autocompletableNodeNames(contextNodeName?: string) {
const activeNode =
contextNodeName === undefined
? useNDVStore().activeNode
: useWorkflowsStore().getNodeByName(contextNodeName);
if (!activeNode) return [];

View File

@@ -21,17 +21,20 @@ import { typescriptWorkerFacet } from './facet';
import { typescriptHoverTooltips } from './hoverTooltip';
import { linter } from '@codemirror/lint';
import { typescriptLintSource } from './linter';
import type { TargetNodeParameterContext } from '@/Interface';
import { TARGET_NODE_PARAMETER_FACET } from '../../completions/constants';
export function useTypescript(
view: MaybeRefOrGetter<EditorView | undefined>,
mode: MaybeRefOrGetter<CodeExecutionMode>,
id: MaybeRefOrGetter<string>,
targetNodeParameterContext?: MaybeRefOrGetter<TargetNodeParameterContext>,
) {
const { getInputDataWithPinned, getSchemaForExecutionData } = useDataSchema();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const { debounce } = useDebounce();
const activeNodeName = ndvStore.activeNodeName;
const activeNodeName = toValue(targetNodeParameterContext)?.nodeName ?? ndvStore.activeNodeName;
const worker = ref<Comlink.Remote<LanguageServiceWorker>>();
const webWorker = ref<Worker>();
@@ -64,7 +67,9 @@ export function useTypescript(
.getBinaryData(
execution?.data?.resultData?.runData ?? null,
node.name,
ndvStore.ndvInputRunIndex ?? 0,
toValue(targetNodeParameterContext) === undefined
? (ndvStore.ndvInputRunIndex ?? 0)
: 0,
0,
)
.filter((data) => Boolean(data && Object.keys(data).length));
@@ -88,6 +93,7 @@ export function useTypescript(
return [
typescriptWorkerFacet.of({ worker: worker.value }),
TARGET_NODE_PARAMETER_FACET.of(toValue(targetNodeParameterContext)),
new LanguageSupport(javascriptLanguage, [
javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }),
]),