From 701c31cfbcd5644d59c1bcbebacc27314c3c5d21 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Wed, 18 Jun 2025 17:05:32 +0200 Subject: [PATCH] feat(editor): Propagate targetNodeParameterContext throughout expression resolution logic (no-changelog) (#16476) --- packages/frontend/editor-ui/src/Interface.ts | 5 ++ .../ExpressionEditorModalInput.vue | 8 ++- .../src/composables/useCodeEditor.ts | 10 +++- .../src/composables/useExpressionEditor.ts | 16 ++++-- .../src/composables/useResolvedExpression.ts | 5 +- .../completions/blank.completions.ts | 2 +- .../completions/completions.test.ts | 5 +- .../codemirror/completions/constants.ts | 9 ++++ .../completions/dollar.completions.ts | 11 ++-- .../plugins/codemirror/completions/utils.ts | 54 ++++++++++++------- .../typescript/client/useTypescript.ts | 10 +++- 11 files changed, 99 insertions(+), 36 deletions(-) diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 01619cb19c2..fba42dcf2c3 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -1058,6 +1058,11 @@ export interface NDVState { highlightDraggables: boolean; } +export type TargetNodeParameterContext = { + nodeName: string; + parameterPath: string; +}; + export interface NotificationOptions extends Partial { message: string | ElementNotificationOptions['message']; } diff --git a/packages/frontend/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/frontend/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index 4a568e293ee..9d01a1cc5ee 100644 --- a/packages/frontend/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/frontend/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -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( diff --git a/packages/frontend/editor-ui/src/composables/useCodeEditor.ts b/packages/frontend/editor-ui/src/composables/useCodeEditor.ts index c39bd7d6ccb..f1cc44454d7 100644 --- a/packages/frontend/editor-ui/src/composables/useCodeEditor.ts +++ b/packages/frontend/editor-ui/src/composables/useCodeEditor.ts @@ -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 = ({ language, languageParams, placeholder, + targetNodeParameterContext = undefined, extensions = [], isReadOnly = false, theme = {}, @@ -77,6 +79,7 @@ export const useCodeEditor = ({ language: MaybeRefOrGetter; editorValue?: MaybeRefOrGetter; placeholder?: MaybeRefOrGetter; + targetNodeParameterContext?: MaybeRefOrGetter; extensions?: MaybeRefOrGetter; isReadOnly?: MaybeRefOrGetter; theme?: MaybeRefOrGetter<{ @@ -106,7 +109,12 @@ export const useCodeEditor = ({ 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) { diff --git a/packages/frontend/editor-ui/src/composables/useExpressionEditor.ts b/packages/frontend/editor-ui/src/composables/useExpressionEditor.ts index 53508a91f9e..0c3f34ed1f8 100644 --- a/packages/frontend/editor-ui/src/composables/useExpressionEditor.ts +++ b/packages/frontend/editor-ui/src/composables/useExpressionEditor.ts @@ -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; editorValue?: MaybeRefOrGetter; + targetNodeParameterContext?: MaybeRefOrGetter; extensions?: MaybeRefOrGetter; additionalData?: MaybeRefOrGetter; skipSegments?: MaybeRefOrGetter; @@ -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 = { additionalKeys: toValue(additionalData) }; - if (ndvStore.isInputParentOfActiveNode) { + let opts: Record = { + additionalKeys: toValue(additionalData), + targetNodeParameterContext, + }; + if ( + toValue(targetNodeParameterContext) === undefined && + ndvStore.isInputParentOfActiveNode + ) { opts = { targetItem: target ?? undefined, inputNodeName: ndvStore.ndvInputNodeName, diff --git a/packages/frontend/editor-ui/src/composables/useResolvedExpression.ts b/packages/frontend/editor-ui/src/composables/useResolvedExpression.ts index 9797e9e1958..3aaf6a172ef 100644 --- a/packages/frontend/editor-ui/src/composables/useResolvedExpression.ts +++ b/packages/frontend/editor-ui/src/composables/useResolvedExpression.ts @@ -12,11 +12,13 @@ export function useResolvedExpression({ additionalData, isForCredential, stringifyObject, + contextNodeName, }: { expression: MaybeRefOrGetter; additionalData?: MaybeRefOrGetter; isForCredential?: MaybeRefOrGetter; stringifyObject?: MaybeRefOrGetter; + contextNodeName?: MaybeRefOrGetter; }) { 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, diff --git a/packages/frontend/editor-ui/src/plugins/codemirror/completions/blank.completions.ts b/packages/frontend/editor-ui/src/plugins/codemirror/completions/blank.completions.ts index eed640f6442..51e2e003396 100644 --- a/packages/frontend/editor-ui/src/plugins/codemirror/completions/blank.completions.ts +++ b/packages/frontend/editor-ui/src/plugins/codemirror/completions/blank.completions.ts @@ -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, }; } diff --git a/packages/frontend/editor-ui/src/plugins/codemirror/completions/completions.test.ts b/packages/frontend/editor-ui/src/plugins/codemirror/completions/completions.test.ts index 37ee6270978..466a960dec9 100644 --- a/packages/frontend/editor-ui/src/plugins/codemirror/completions/completions.test.ts +++ b/packages/frontend/editor-ui/src/plugins/codemirror/completions/completions.test.ts @@ -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: {{ $(| }}', () => { diff --git a/packages/frontend/editor-ui/src/plugins/codemirror/completions/constants.ts b/packages/frontend/editor-ui/src/plugins/codemirror/completions/constants.ts index 9ea39a2d9ac..a47c19fa306 100644 --- a/packages/frontend/editor-ui/src/plugins/codemirror/completions/constants.ts +++ b/packages/frontend/editor-ui/src/plugins/codemirror/completions/constants.ts @@ -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 = { rank: 5, }), }; + +export const TARGET_NODE_PARAMETER_FACET = Facet.define< + TargetNodeParameterContext | undefined, + TargetNodeParameterContext | undefined +>({ + combine: (values) => values[0], +}); diff --git a/packages/frontend/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts b/packages/frontend/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts index 9e94a70c080..63ecab6759c 100644 --- a/packages/frontend/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts +++ b/packages/frontend/editor-ui/src/plugins/codemirror/completions/dollar.completions.ts @@ -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)}')`; diff --git a/packages/frontend/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/frontend/editor-ui/src/plugins/codemirror/completions/utils.ts index 5a4e82db610..ec5b6a50fb1 100644 --- a/packages/frontend/editor-ui/src/plugins/codemirror/completions/utils.ts +++ b/packages/frontend/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -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 []; diff --git a/packages/frontend/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts b/packages/frontend/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts index 642235f0794..793b0f22d90 100644 --- a/packages/frontend/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts +++ b/packages/frontend/editor-ui/src/plugins/codemirror/typescript/client/useTypescript.ts @@ -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, mode: MaybeRefOrGetter, id: MaybeRefOrGetter, + targetNodeParameterContext?: MaybeRefOrGetter, ) { 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>(); const webWorker = ref(); @@ -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 }), ]),