fix(editor): Make expression resolution work in embedded ndv (no-changelog) (#17221)

This commit is contained in:
Suguru Inoue
2025-07-14 12:34:40 +02:00
committed by GitHub
parent d002cc3f7d
commit 075efba2bb
7 changed files with 338 additions and 38 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import ExperimentalCanvasNodeSettings from './ExperimentalCanvasNodeSettings.vue';
import { onBeforeUnmount, ref, computed } from 'vue';
import { onBeforeUnmount, ref, computed, provide } from 'vue';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExperimentalNdvStore } from '../experimentalNdv.store';
@@ -8,6 +8,9 @@ import NodeTitle from '@/components/NodeTitle.vue';
import { N8nIcon, N8nIconButton } from '@n8n/design-system';
import { useVueFlow } from '@vue-flow/core';
import { watchOnce } from '@vueuse/core';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
import { useEnvironmentsStore } from '@/stores/environments.ee.store';
import type { ExpressionLocalResolveContext } from '@/types/expressions';
const { nodeId, isReadOnly, isConfigurable } = defineProps<{
nodeId: string;
@@ -56,6 +59,55 @@ const isVisible = computed(() =>
);
const isOnceVisible = ref(isVisible.value);
provide(
ExpressionLocalResolveContextSymbol,
computed<ExpressionLocalResolveContext | undefined>(() => {
if (!node.value) {
return undefined;
}
const workflow = workflowsStore.getCurrentWorkflow();
const runIndex = 0; // not changeable for now
const execution = workflowsStore.workflowExecutionData;
const nodeName = node.value.name;
function findInputNode(): ExpressionLocalResolveContext['inputNode'] {
const taskData = (execution?.data?.resultData.runData[nodeName] ?? [])[runIndex];
const source = taskData?.source[0];
if (source) {
return {
name: source.previousNode,
branchIndex: source.previousNodeOutput ?? 0,
runIndex: source.previousNodeRun ?? 0,
};
}
const inputs = workflow.getParentNodesByDepth(nodeName, 1);
if (inputs.length > 0) {
return {
name: inputs[0].name,
branchIndex: inputs[0].indicies[0] ?? 0,
runIndex: 0,
};
}
return undefined;
}
return {
localResolve: true,
envVars: useEnvironmentsStore().variablesAsObject,
workflow,
execution,
nodeName,
additionalKeys: {},
inputNode: findInputNode(),
};
}),
);
watchOnce(isVisible, (visible) => {
isOnceVisible.value = isOnceVisible.value || visible;
});

View File

@@ -1,5 +1,6 @@
import {
computed,
inject,
onBeforeUnmount,
onMounted,
ref,
@@ -15,7 +16,7 @@ import { ensureSyntaxTree } from '@codemirror/language';
import type { IDataObject } from 'n8n-workflow';
import { Expression, ExpressionExtensions } from 'n8n-workflow';
import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants';
import { EXPRESSION_EDITOR_PARSER_TIMEOUT, ExpressionLocalResolveContextSymbol } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import type { TargetItem, TargetNodeParameterContext } from '@/Interface';
@@ -75,6 +76,10 @@ export const useExpressionEditor = ({
const autocompleteStatus = ref<'pending' | 'active' | null>(null);
const dragging = ref(false);
const hasChanges = ref(false);
const expressionLocalResolveContext = inject(
ExpressionLocalResolveContextSymbol,
computed(() => undefined),
);
const emitChanges = debounce(onChange, 300);
@@ -307,7 +312,12 @@ export const useExpressionEditor = ({
};
try {
if (!ndvStore.activeNode && toValue(targetNodeParameterContext) === undefined) {
if (expressionLocalResolveContext.value) {
result.resolved = workflowHelpers.resolveExpression('=' + resolvable, undefined, {
...expressionLocalResolveContext.value,
additionalKeys: toValue(additionalData),
});
} else if (!ndvStore.activeNode && toValue(targetNodeParameterContext) === undefined) {
// e.g. credential modal
result.resolved = Expression.resolveWithoutWorkflow(resolvable, toValue(additionalData));
} else {

View File

@@ -4,8 +4,19 @@ import { isExpression as isExpressionUtil, stringifyExpressionResult } from '@/u
import debounce from 'lodash/debounce';
import { createResultError, createResultOk, type IDataObject, type Result } from 'n8n-workflow';
import { computed, onMounted, ref, toRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
import {
computed,
onMounted,
ref,
toRef,
toValue,
inject,
type MaybeRefOrGetter,
watch,
} from 'vue';
import { useWorkflowHelpers, type ResolveParameterOptions } from './useWorkflowHelpers';
import { ExpressionLocalResolveContextSymbol } from '@/constants';
import type { ExpressionLocalResolveContext } from '@/types/expressions';
export function useResolvedExpression({
expression,
@@ -25,6 +36,11 @@ export function useResolvedExpression({
const { resolveExpression } = useWorkflowHelpers();
const expressionLocalResolveCtx = inject(
ExpressionLocalResolveContextSymbol,
computed(() => undefined),
);
const resolvedExpression = ref<unknown>(null);
const resolvedExpressionString = ref('');
@@ -37,29 +53,27 @@ export function useResolvedExpression({
);
const isExpression = computed(() => isExpressionUtil(toValue(expression)));
function resolve(): Result<unknown, Error> {
function resolve(ctx?: ExpressionLocalResolveContext): Result<unknown, Error> {
const expressionString = toValue(expression);
if (!isExpression.value || typeof expressionString !== 'string') {
return { ok: true, result: '' };
}
let options: ResolveParameterOptions = {
const options: ResolveParameterOptions | ExpressionLocalResolveContext = ctx ?? {
isForCredential: toValue(isForCredential),
additionalKeys: toValue(additionalData),
contextNodeName: toValue(contextNodeName),
...(contextNodeName === undefined && ndvStore.isInputParentOfActiveNode
? {
targetItem: targetItem.value ?? undefined,
inputNodeName: ndvStore.ndvInputNodeName,
inputRunIndex: ndvStore.ndvInputRunIndex,
inputBranchIndex: ndvStore.ndvInputBranchIndex,
}
: {}),
};
if (contextNodeName === undefined && ndvStore.isInputParentOfActiveNode) {
options = {
...options,
targetItem: targetItem.value ?? undefined,
inputNodeName: ndvStore.ndvInputNodeName,
inputRunIndex: ndvStore.ndvInputRunIndex,
inputBranchIndex: ndvStore.ndvInputBranchIndex,
};
}
try {
const resolvedValue = resolveExpression(
expressionString,
@@ -78,7 +92,7 @@ export function useResolvedExpression({
function updateExpression() {
if (isExpression.value) {
const resolved = resolve();
const resolved = resolve(expressionLocalResolveCtx.value);
resolvedExpression.value = resolved.ok ? resolved.result : null;
resolvedExpressionString.value = stringifyExpressionResult(resolved, hasRunData.value);
} else {
@@ -89,6 +103,7 @@ export function useResolvedExpression({
watch(
[
expressionLocalResolveCtx,
toRef(expression),
() => workflowsStore.getWorkflowExecution,
() => workflowsStore.getWorkflowRunData,

View File

@@ -1,14 +1,24 @@
import type { IExecutionResponse, IWorkflowDb } from '@/Interface';
import type { WorkflowData } from '@n8n/rest-api-client/api/workflows';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { resolveParameter, useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
import { useTagsStore } from '@/stores/tags.store';
import { useUIStore } from '@/stores/ui.store';
import { createTestWorkflow } from '@/__tests__/mocks';
import { WEBHOOK_NODE_TYPE, type AssignmentCollectionValue } from 'n8n-workflow';
import {
createTestNode,
createTestTaskData,
createTestWorkflow,
createTestWorkflowExecutionResponse,
createTestWorkflowObject,
} from '@/__tests__/mocks';
import {
NodeConnectionTypes,
WEBHOOK_NODE_TYPE,
type AssignmentCollectionValue,
} from 'n8n-workflow';
import * as apiWebhooks from '@n8n/rest-api-client/api/webhooks';
import { mockedStore } from '@/__tests__/utils';
@@ -871,3 +881,79 @@ describe('useWorkflowHelpers', () => {
});
});
});
describe(resolveParameter, () => {
describe('with local resolve context', () => {
it('should resolve parameter without execution data', () => {
const result = resolveParameter(
{
f0: '={{ 2 + 2 }}',
f1: '={{ $vars.foo }}',
f2: '={{ String($exotic).toUpperCase() }}',
},
{
localResolve: true,
envVars: {
foo: 'hello!',
},
additionalKeys: {
$exotic: true,
},
workflow: createTestWorkflowObject({
nodes: [createTestNode({ name: 'n0' })],
}),
execution: null,
nodeName: 'n0',
},
);
expect(result).toEqual({ f0: 4, f1: 'hello!', f2: 'TRUE' });
});
it('should resolve parameter with execution data', () => {
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'n0' }), createTestNode({ name: 'n1' })],
connections: {
n0: {
[NodeConnectionTypes.Main]: [
[{ type: NodeConnectionTypes.Main, index: 0, node: 'n1' }],
],
},
},
});
const result = resolveParameter(
{
f0: '={{ $json }}',
f1: '={{ $("n0").item.json }}',
},
{
localResolve: true,
envVars: {},
additionalKeys: {},
workflow: createTestWorkflowObject(workflowData),
execution: createTestWorkflowExecutionResponse({
workflowData,
data: {
resultData: {
runData: {
n0: [
createTestTaskData({
data: { [NodeConnectionTypes.Main]: [[{ json: { foo: 777 } }]] },
}),
],
},
},
},
}),
nodeName: 'n1',
inputNode: { name: 'n0', branchIndex: 0, runIndex: 0 },
},
);
expect(result).toEqual({
f0: { foo: 777 },
f1: { foo: 777 },
});
});
});
});

View File

@@ -15,6 +15,8 @@ import type {
INodeParameters,
INodeProperties,
INodeTypes,
IPinData,
IRunData,
IRunExecutionData,
IWebhookDescription,
IWorkflowDataProxyAdditionalKeys,
@@ -30,6 +32,7 @@ import {
import type {
ICredentialsResponse,
IExecutionResponse,
INodeTypesMaxCount,
INodeUi,
IWorkflowDb,
@@ -58,6 +61,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import { useTagsStore } from '@/stores/tags.store';
import { useWorkflowsEEStore } from '@/stores/workflows.ee.store';
import { findWebhook } from '@n8n/rest-api-client/api/webhooks';
import type { ExpressionLocalResolveContext } from '@/types/expressions';
export type ResolveParameterOptions = {
targetItem?: TargetItem;
@@ -71,11 +75,54 @@ export type ResolveParameterOptions = {
export function resolveParameter<T = IDataObject>(
parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
opts: ResolveParameterOptions | ExpressionLocalResolveContext = {},
): T | null {
if ('localResolve' in opts && opts.localResolve) {
return resolveParameterImpl(
parameter,
() => opts.workflow,
opts.envVars,
opts.workflow.getNode(opts.nodeName),
opts.execution,
true,
opts.workflow.pinData,
{
inputNodeName: opts.inputNode?.name,
inputRunIndex: opts.inputNode?.runIndex,
inputBranchIndex: opts.inputNode?.branchIndex,
additionalKeys: opts.additionalKeys,
},
);
}
const workflowsStore = useWorkflowsStore();
return resolveParameterImpl(
parameter,
workflowsStore.getCurrentWorkflow,
useEnvironmentsStore().variablesAsObject,
useNDVStore().activeNode,
workflowsStore.workflowExecutionData,
workflowsStore.shouldReplaceInputDataWithPinData,
workflowsStore.pinnedWorkflowData,
opts,
);
}
// TODO: move to separate file
function resolveParameterImpl<T = IDataObject>(
parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
getContextWorkflow: () => Workflow,
envVars: Record<string, string | boolean | number>,
ndvActiveNode: INodeUi | null,
executionData: IExecutionResponse | null,
shouldReplaceInputDataWithPinData: boolean,
pinData: IPinData | undefined,
opts: ResolveParameterOptions = {},
): T | null {
let itemIndex = opts?.targetItem?.itemIndex || 0;
const workflow = getCurrentWorkflow();
const workflow = getContextWorkflow();
const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
$execution: {
@@ -84,7 +131,7 @@ export function resolveParameter<T = IDataObject>(
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
resumeFormUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
$vars: useEnvironmentsStore().variablesAsObject,
$vars: envVars,
// deprecated
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
@@ -113,17 +160,15 @@ export function resolveParameter<T = IDataObject>(
const inputName = NodeConnectionTypes.Main;
const activeNode =
useNDVStore().activeNode ?? useWorkflowsStore().getNodeByName(opts.contextNodeName || '');
const activeNode = ndvActiveNode ?? workflow.getNode(opts.contextNodeName || '');
let contextNode = activeNode;
if (activeNode) {
contextNode = workflow.getParentMainInputNode(activeNode);
}
const workflowRunData = useWorkflowsStore().getWorkflowRunData;
const workflowRunData = executionData?.data?.resultData.runData ?? null;
let parentNode = workflow.getParentNodes(contextNode!.name, inputName, 1);
const executionData = useWorkflowsStore().getWorkflowExecution;
let runIndexParent = opts?.inputRunIndex ?? 0;
const nodeConnection = workflow.getNodeConnectionIndexes(contextNode!.name, parentNode[0]);
@@ -159,13 +204,26 @@ export function resolveParameter<T = IDataObject>(
contextNode!.name,
inputName,
runIndexParent,
getContextWorkflow,
shouldReplaceInputDataWithPinData,
pinData,
executionData?.data?.resultData.runData ?? null,
nodeConnection,
);
if (_connectionInputData === null && contextNode && activeNode?.name !== contextNode.name) {
// For Sub-Nodes connected to Trigger-Nodes use the data of the root-node
// (Gets for example used by the Memory connected to the Chat-Trigger-Node)
const _executeData = executeData([contextNode.name], contextNode.name, inputName, 0);
const _executeData = executeDataImpl(
[contextNode.name],
contextNode.name,
inputName,
0,
getContextWorkflow,
shouldReplaceInputDataWithPinData,
pinData,
executionData?.data?.resultData.runData ?? null,
);
_connectionInputData = get(_executeData, ['data', inputName, 0], null);
}
@@ -206,17 +264,30 @@ export function resolveParameter<T = IDataObject>(
) {
runIndexCurrent = workflowRunData[contextNode!.name].length - 1;
}
let _executeData = executeData(
let _executeData = executeDataImpl(
parentNode,
contextNode!.name,
inputName,
runIndexCurrent,
getContextWorkflow,
shouldReplaceInputDataWithPinData,
pinData,
executionData?.data?.resultData.runData ?? null,
runIndexParent,
);
if (!_executeData.source) {
// fallback to parent's run index for multi-output case
_executeData = executeData(parentNode, contextNode!.name, inputName, runIndexParent);
_executeData = executeDataImpl(
parentNode,
contextNode!.name,
inputName,
runIndexParent,
getContextWorkflow,
shouldReplaceInputDataWithPinData,
pinData,
executionData?.data?.resultData.runData ?? null,
);
}
return workflow.expression.getParameterValue(
@@ -308,16 +379,30 @@ function getNodeTypes(): INodeTypes {
return useWorkflowsStore().getNodeTypes();
}
// TODO: move to separate file
// Returns connectionInputData to be able to execute an expression.
function connectionInputData(
parentNode: string[],
currentNode: string,
inputName: string,
runIndex: number,
getContextWorkflow: () => Workflow,
shouldReplaceInputDataWithPinData: boolean,
pinData: IPinData | undefined,
workflowRunData: IRunData | null,
nodeConnection: INodeConnection = { sourceIndex: 0, destinationIndex: 0 },
): INodeExecutionData[] | null {
let connectionInputData: INodeExecutionData[] | null = null;
const _executeData = executeData(parentNode, currentNode, inputName, runIndex);
const _executeData = executeDataImpl(
parentNode,
currentNode,
inputName,
runIndex,
getContextWorkflow,
shouldReplaceInputDataWithPinData,
pinData,
workflowRunData,
);
if (parentNode.length) {
if (
!Object.keys(_executeData.data).length ||
@@ -351,6 +436,33 @@ export function executeData(
inputName: string,
runIndex: number,
parentRunIndex?: number,
): IExecuteData {
const workflowsStore = useWorkflowsStore();
return executeDataImpl(
parentNodes,
currentNode,
inputName,
runIndex,
workflowsStore.getCurrentWorkflow,
workflowsStore.shouldReplaceInputDataWithPinData,
workflowsStore.pinnedWorkflowData,
workflowsStore.getWorkflowRunData,
parentRunIndex,
);
}
// TODO: move to separate file
function executeDataImpl(
parentNodes: string[],
currentNode: string,
inputName: string,
runIndex: number,
getContextWorkflow: () => Workflow,
shouldReplaceInputDataWithPinData: boolean,
pinData: IPinData | undefined,
workflowRunData: IRunData | null,
parentRunIndex?: number,
): IExecuteData {
const executeData = {
node: {},
@@ -360,12 +472,10 @@ export function executeData(
parentRunIndex = parentRunIndex ?? runIndex;
const workflowsStore = useWorkflowsStore();
// Find the parent node which has data
for (const parentNodeName of parentNodes) {
if (workflowsStore.shouldReplaceInputDataWithPinData) {
const parentPinData = workflowsStore.pinnedWorkflowData![parentNodeName];
if (shouldReplaceInputDataWithPinData) {
const parentPinData = pinData?.[parentNodeName];
// populate `executeData` from `pinData`
@@ -378,7 +488,6 @@ export function executeData(
}
// populate `executeData` from `runData`
const workflowRunData = workflowsStore.getWorkflowRunData;
if (workflowRunData === null) {
return executeData;
}
@@ -398,7 +507,7 @@ export function executeData(
[inputName]: workflowRunData[currentNode][runIndex].source,
};
} else {
const workflow = getCurrentWorkflow();
const workflow = getContextWorkflow();
let previousNodeOutput: number | undefined;
// As the node can be connected through either of the outputs find the correct one
@@ -739,7 +848,7 @@ export function useWorkflowHelpers() {
function resolveExpression(
expression: string,
siblingParameters: INodeParameters = {},
opts: ResolveParameterOptions & { c?: number } = {},
opts: ResolveParameterOptions | ExpressionLocalResolveContext = {},
stringifyObject = true,
) {
const parameters = {

View File

@@ -10,7 +10,8 @@ import type {
CanvasNodeHandleInjectionData,
CanvasNodeInjectionData,
} from '@/types';
import type { InjectionKey, Ref } from 'vue';
import type { ComputedRef, InjectionKey, Ref } from 'vue';
import type { ExpressionLocalResolveContext } from './types/expressions';
export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes
export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow request metadata (i.e. headers) size in bytes
@@ -928,6 +929,9 @@ export const CanvasNodeKey = 'canvasNode' as unknown as InjectionKey<CanvasNodeI
export const CanvasNodeHandleKey =
'canvasNodeHandle' as unknown as InjectionKey<CanvasNodeHandleInjectionData>;
export const PiPWindowSymbol = 'PiPWindow' as unknown as InjectionKey<Ref<Window | undefined>>;
export const ExpressionLocalResolveContextSymbol = Symbol(
'ExpressionLocalResolveContext',
) as InjectionKey<ComputedRef<ExpressionLocalResolveContext | undefined>>;
/** Auth */
export const APP_MODALS_ELEMENT_ID = 'app-modals';

View File

@@ -1,3 +1,6 @@
import type { Basic, IExecutionResponse } from '@/Interface';
import type { IWorkflowDataProxyAdditionalKeys, Workflow } from 'n8n-workflow';
type Range = { from: number; to: number };
export type RawSegment = { text: string; token: string } & Range;
@@ -27,3 +30,24 @@ export namespace ColoringStateEffect {
state?: ResolvableState;
} & Range;
}
/**
* Collection of data, intended to be sufficient for resolving expressions
* in parameter name/value without referencing global state
*/
export interface ExpressionLocalResolveContext {
localResolve: true;
envVars: Record<string, Basic>;
additionalKeys: IWorkflowDataProxyAdditionalKeys;
workflow: Workflow;
execution: IExecutionResponse | null;
nodeName: string;
/**
* Allowed to be undefined (e.g., trigger node, partial execution)
*/
inputNode?: {
name: string;
runIndex: number;
branchIndex: number;
};
}