This commit is contained in:
Suguru Inoue 2026-05-12 15:56:28 +02:00 committed by GitHub
commit aee89d51e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 433 additions and 443 deletions

View File

@ -329,17 +329,11 @@ export function createTestWorkflowExecutionResponse(
export function createTestExpressionLocalResolveContext(
data: Partial<ExpressionLocalResolveContext> = {},
): ExpressionLocalResolveContext {
const workflow = data.workflow ?? createTestWorkflowObject();
return {
localResolve: true,
workflow,
nodeName: 'n0',
inputNode: { name: 'n1', runIndex: 0, branchIndex: 0 },
envVars: {},
additionalKeys: {},
connections: workflow.connectionsBySourceNode,
execution: null,
...data,
};
}

View File

@ -27,7 +27,7 @@ import { useExposeCssVar } from '@/app/composables/useExposeCssVar';
import { useFloatingUiOffsets } from '@/app/composables/useFloatingUiOffsets';
import { useWorkflowId } from '@/app/composables/useWorkflowId';
import { WorkflowDocumentStoreKey, WorkflowIdKey } from '@/app/constants/injectionKeys';
import type { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import type { WorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
const route = useRoute();
const rootStore = useRootStore();
@ -53,9 +53,7 @@ const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
const hasContentFooter = ref(false);
const workflowId = useWorkflowId();
const currentWorkflowDocumentStore = shallowRef<ReturnType<typeof useWorkflowDocumentStore> | null>(
null,
);
const currentWorkflowDocumentStore = shallowRef<WorkflowDocumentStore | null>(null);
provide(WorkflowIdKey, workflowId);
provide(WorkflowDocumentStoreKey, currentWorkflowDocumentStore);

View File

@ -285,6 +285,7 @@ export function useNodeExecution(
const updateInformation = await generateCodeForAiTransform(
prompt,
`parameters.${AI_TRANSFORM_JS_CODE}`,
workflowDocumentStore.value.documentId,
5,
);

View File

@ -21,9 +21,9 @@ import type { IWorkflowDb } from '@/Interface';
import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import {
type useWorkflowDocumentStore,
useWorkflowDocumentStore as createWorkflowDocumentStore,
createWorkflowDocumentId,
type WorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { useWorkflowImport } from '@/app/composables/useWorkflowImport';
@ -31,7 +31,7 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
interface PostMessageHandlerDeps {
workflowState: WorkflowState;
currentWorkflowDocumentStore: ShallowRef<ReturnType<typeof useWorkflowDocumentStore> | null>;
currentWorkflowDocumentStore: ShallowRef<WorkflowDocumentStore | null>;
currentNDVStore: ShallowRef<ReturnType<typeof useNDVStore> | null>;
}

View File

@ -107,7 +107,11 @@ export function useResolvedExpression({
if (currentInvocation !== updateExpressionInvocation) return;
resolvedExpression.value = resolved.ok ? resolved.result : null;
resolvedExpressionString.value = stringifyExpressionResult(resolved, hasRunData.value);
resolvedExpressionString.value = stringifyExpressionResult(
resolved,
workflowDocumentStore.value.getPinDataSnapshot(),
hasRunData.value,
);
} else {
resolvedExpression.value = null;
resolvedExpressionString.value = '';

View File

@ -8,28 +8,13 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useTagsStore } from '@/features/shared/tags/tags.store';
import { useUIStore } from '@/app/stores/ui.store';
import {
createMockNodeTypes,
createTestExpressionLocalResolveContext,
createTestNode,
createTestTaskData,
createTestWorkflow,
createTestWorkflowExecutionResponse,
createTestWorkflowObject,
mockLoadedNodeType,
mockNodeTypeDescription,
} from '@/__tests__/mocks';
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import {
CHAT_TRIGGER_NODE_TYPE,
createRunExecutionData,
NodeConnectionTypes,
WEBHOOK_NODE_TYPE,
} from 'n8n-workflow';
import { CHAT_TRIGGER_NODE_TYPE, WEBHOOK_NODE_TYPE } from 'n8n-workflow';
import type { AssignmentCollectionValue, IConnections } from 'n8n-workflow';
import * as apiWebhooks from '@n8n/rest-api-client/api/webhooks';
import { mockedStore } from '@/__tests__/utils';
import { SLACK_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '../constants';
import { SET_NODE_TYPE, SLACK_TRIGGER_NODE_TYPE } from '../constants';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
@ -1190,94 +1175,54 @@ describe('useWorkflowHelpers', () => {
});
describe(resolveParameter, () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
});
describe('with local resolve context', () => {
it('should resolve parameter without execution data', async () => {
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'n0' })],
});
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowData.id),
);
workflowDocumentStore.hydrate(workflowData);
const result = await resolveParameter(
{
f0: '={{ 2 + 2 }}',
f1: '={{ $vars.foo }}',
f2: '={{ String($exotic).toUpperCase() }}',
},
workflowDocumentStore.documentId,
{
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', async () => {
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'n0' }), createTestNode({ name: 'n1' })],
connections: {
n0: {
[NodeConnectionTypes.Main]: [
[{ type: NodeConnectionTypes.Main, index: 0, node: 'n1' }],
],
},
},
});
const result = await resolveParameter(
{
f0: '={{ $json }}',
f1: '={{ $("n0").item.json }}',
},
createTestExpressionLocalResolveContext({
workflow: createTestWorkflowObject(workflowData),
execution: createTestWorkflowExecutionResponse({
workflowData,
data: createRunExecutionData({
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 },
});
expect(result).toEqual({ f0: 4, f2: 'TRUE' });
});
it('should include $tool in additionalKeys for hitl tool node types', async () => {
const toolNodeType = 'n8n-nodes-base.someHitlTool';
const toolNodeTypes = createMockNodeTypes({
[toolNodeType]: mockLoadedNodeType(toolNodeType),
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'toolNode', type: toolNodeType })],
});
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowData.id),
);
workflowDocumentStore.hydrate(workflowData);
const result = await resolveParameter(
{
toolName: '={{ $tool.name }}',
toolParams: '={{ $tool.parameters }}',
},
workflowDocumentStore.documentId,
{
localResolve: true,
workflow: createTestWorkflowObject({
nodes: [createTestNode({ name: 'toolNode', type: toolNodeType })],
nodeTypes: toolNodeTypes,
}),
execution: null,
nodeName: 'toolNode',
additionalKeys: {},
},
@ -1288,16 +1233,20 @@ describe(resolveParameter, () => {
});
it('should not include $tool in additionalKeys for non-tool node types', async () => {
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'regularNode', type: SET_NODE_TYPE })],
});
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowData.id),
);
workflowDocumentStore.hydrate(workflowData);
const result = await resolveParameter(
{
toolCheck: '={{ $tool }}',
},
workflowDocumentStore.documentId,
{
localResolve: true,
workflow: createTestWorkflowObject({
nodes: [createTestNode({ name: 'regularNode', type: SET_NODE_TYPE })],
}),
execution: null,
nodeName: 'regularNode',
additionalKeys: {},
},
@ -1308,21 +1257,21 @@ describe(resolveParameter, () => {
it('should resolve $tool.name expression for tool nodes', async () => {
const toolNodeType = 'n8n-nodes-base.someHitlTool';
const toolNodeTypes = createMockNodeTypes({
[toolNodeType]: mockLoadedNodeType(toolNodeType),
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'hitlTool', type: toolNodeType })],
});
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowData.id),
);
workflowDocumentStore.hydrate(workflowData);
const result = await resolveParameter(
{
message: '={{ "The agent wants to call " + $tool.name }}',
},
workflowDocumentStore.documentId,
{
localResolve: true,
workflow: createTestWorkflowObject({
nodes: [createTestNode({ name: 'hitlTool', type: toolNodeType })],
nodeTypes: toolNodeTypes,
}),
execution: null,
nodeName: 'hitlTool',
additionalKeys: {},
},
@ -1333,21 +1282,21 @@ describe(resolveParameter, () => {
it('should resolve $tool.parameters expression for hitl tool nodes', async () => {
const toolNodeType = 'n8n-nodes-base.someHitlTool';
const toolNodeTypes = createMockNodeTypes({
[toolNodeType]: mockLoadedNodeType(toolNodeType),
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'someTool', type: toolNodeType })],
});
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowData.id),
);
workflowDocumentStore.hydrate(workflowData);
const result = await resolveParameter(
{
params: '={{ $tool.parameters }}',
},
workflowDocumentStore.documentId,
{
localResolve: true,
workflow: createTestWorkflowObject({
nodes: [createTestNode({ name: 'someTool', type: toolNodeType })],
nodeTypes: toolNodeTypes,
}),
execution: null,
nodeName: 'someTool',
additionalKeys: {},
},

View File

@ -28,8 +28,7 @@ import {
} from 'n8n-workflow';
import * as workflowUtils from 'n8n-workflow/common';
import type { INodeTypesMaxCount, INodeUi, IWorkflowDb, TargetItem, XYPosition } from '@/Interface';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import type { INodeTypesMaxCount, IWorkflowDb, TargetItem, XYPosition } from '@/Interface';
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
import type { ITag } from '@n8n/rest-api-client/api/tags';
import type { WorkflowData, WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
@ -55,8 +54,8 @@ import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
injectWorkflowDocumentStore,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import type { WorkflowObjectAccessors } from '../types';
export type ResolveParameterOptions = {
targetItem?: TargetItem;
@ -71,54 +70,31 @@ export type ResolveParameterOptions = {
export async function resolveParameter<T = IDataObject>(
parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
opts: ResolveParameterOptions | ExpressionLocalResolveContext = {},
workflowDocumentId: WorkflowDocumentId,
opts_: ResolveParameterOptions | ExpressionLocalResolveContext = {},
): Promise<T | null> {
if ('localResolve' in opts && opts.localResolve) {
return await resolveParameterImpl(
parameter,
opts.workflow,
opts.connections,
opts.envVars,
opts.workflow.getNode(opts.nodeName),
opts.execution,
opts.workflow.pinData,
{
inputNodeName: opts.inputNode?.name,
inputRunIndex: opts.inputNode?.runIndex,
inputBranchIndex: opts.inputNode?.branchIndex,
additionalKeys: opts.additionalKeys,
},
);
}
const workflowDocumentStore = useWorkflowDocumentStore(workflowDocumentId);
const envVars = useEnvironmentsStore().variablesAsObject;
const ndvActiveNode =
'localResolve' in opts_ && opts_.localResolve
? workflowDocumentStore.getNodeByName(opts_.nodeName)
: useNDVStore().activeNode;
const opts: ResolveParameterOptions =
'localResolve' in opts_ && opts_.localResolve
? {
inputNodeName: opts_.inputNode?.name,
inputRunIndex: opts_.inputNode?.runIndex,
inputBranchIndex: opts_.inputNode?.branchIndex,
additionalKeys: opts_.additionalKeys,
}
: opts_;
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const workflowObject = workflowDocumentStore.getWorkflowObjectAccessorSnapshot();
const connections = workflowDocumentStore.connectionsBySourceNode;
const executionData = workflowsStore.workflowExecutionData;
const pinData = workflowDocumentStore.getPinDataSnapshot();
return await resolveParameterImpl(
parameter,
workflowDocumentStore.getWorkflowObjectAccessorSnapshot(),
workflowDocumentStore.connectionsBySourceNode,
useEnvironmentsStore().variablesAsObject,
useNDVStore().activeNode,
workflowsStore.workflowExecutionData,
workflowDocumentStore.getPinDataSnapshot(),
opts,
);
}
// TODO: move to separate file
function resolveParameterImpl<T = IDataObject>(
parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
workflowObject: WorkflowObjectAccessors,
connections: IConnections,
envVars: Record<string, string | boolean | number>,
ndvActiveNode: INodeUi | null,
executionData: IExecutionResponse | null,
pinData: IPinData | undefined,
opts: ResolveParameterOptions = {},
): T | null {
let itemIndex = opts?.targetItem?.itemIndex || 0;
const activeNode = ndvActiveNode ?? workflowObject.getNode(opts.contextNodeName || '');
@ -299,6 +275,7 @@ function resolveParameterImpl<T = IDataObject>(
export async function resolveRequiredParameters(
currentParameter: INodeProperties,
parameters: INodeParameters,
workflowDocumentId: WorkflowDocumentId,
opts: ResolveParameterOptions | ExpressionLocalResolveContext = {},
): Promise<IDataObject | null> {
const loadOptionsDependsOn = new Set(currentParameter?.typeOptions?.loadOptionsDependsOn ?? []);
@ -309,10 +286,16 @@ export async function resolveRequiredParameters(
const required = loadOptionsDependsOn.has(name);
if (required) {
return [name, await resolveParameter(parameter as NodeParameterValue, opts)];
return [
name,
await resolveParameter(parameter as NodeParameterValue, workflowDocumentId, opts),
];
} else {
try {
return [name, await resolveParameter(parameter as NodeParameterValue, opts)];
return [
name,
await resolveParameter(parameter as NodeParameterValue, workflowDocumentId, opts),
];
} catch (error) {
// ignore any expressions errors for non required parameters
return [name, null];
@ -651,7 +634,11 @@ export function useWorkflowHelpers() {
__xxxxxxx__: expression,
...siblingParameters,
};
const returnData: IDataObject | null = await resolveParameter(parameters, opts);
const returnData: IDataObject | null = await resolveParameter(
parameters,
workflowDocumentStore.value.documentId,
opts,
);
if (!returnData) {
return null;
}

View File

@ -6,14 +6,14 @@ import type { INodeUi, IWorkflowDb } from '@/Interface';
import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
import { getNodesWithNormalizedPosition } from '@/app/utils/nodeViewUtils';
import {
type useWorkflowDocumentStore,
type WorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
export function useWorkflowImport(
currentWorkflowDocumentStore: ShallowRef<ReturnType<typeof useWorkflowDocumentStore> | null>,
currentWorkflowDocumentStore: ShallowRef<WorkflowDocumentStore | null>,
currentNDVStore: ShallowRef<ReturnType<typeof useNDVStore> | null>,
) {
const route = useRoute();

View File

@ -8,7 +8,7 @@ import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
import type { TelemetryContext } from '@/app/types/telemetry';
import type { WorkflowState } from '@/app/composables/useWorkflowState';
import type { useExecutionDataStore } from '@/app/stores/executionData.store';
import type { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import type { WorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import type { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import type { useNDVStore } from '@/features/ndv/shared/ndv.store';
@ -23,9 +23,8 @@ export const ExpressionLocalResolveContextSymbol: InjectionKey<
> = Symbol('ExpressionLocalResolveContext');
export const TelemetryContextSymbol: InjectionKey<TelemetryContext> = Symbol('TelemetryContext');
export const WorkflowStateKey: InjectionKey<WorkflowState> = Symbol('WorkflowState');
export const WorkflowDocumentStoreKey: InjectionKey<
ShallowRef<ReturnType<typeof useWorkflowDocumentStore> | null>
> = Symbol('WorkflowDocumentStore');
export const WorkflowDocumentStoreKey: InjectionKey<ShallowRef<WorkflowDocumentStore | null>> =
Symbol('WorkflowDocumentStore');
export const ExecutionDataStoreKey: InjectionKey<
ShallowRef<ReturnType<typeof useExecutionDataStore> | null>
> = Symbol('ExecutionDataStore');

View File

@ -369,6 +369,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
}
return {
documentId: id,
workflowId,
workflowVersion,
...workflowDocumentName,
@ -406,6 +407,8 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
})();
}
export type WorkflowDocumentStore = ReturnType<typeof useWorkflowDocumentStore>;
/**
* Disposes a workflow document store instance.
* Call this when a workflow document is unloaded (e.g., when navigating away from NodeView).
@ -413,7 +416,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
* Pinia's $dispose removes the store from its registry, but not from pinia.state.
* Remove the state entry as well so recreating this scoped store starts clean.
*/
export function disposeWorkflowDocumentStore(store: ReturnType<typeof useWorkflowDocumentStore>) {
export function disposeWorkflowDocumentStore(store: WorkflowDocumentStore) {
const pinia = getActivePinia();
store.$dispose();
@ -429,9 +432,7 @@ export function disposeWorkflowDocumentStore(store: ReturnType<typeof useWorkflo
* Use this in composables/stores that need to interact with the current workflow's
* document store and avoid calling this outside a component tree.
*/
export function injectWorkflowDocumentStore(): ShallowRef<
ReturnType<typeof useWorkflowDocumentStore>
> {
export function injectWorkflowDocumentStore(): ShallowRef<WorkflowDocumentStore> {
const workflowsStore = useWorkflowsStore();
const fallback = computed(() => {
// TODO: once usages outside of a component tree is eliminated,

View File

@ -1,7 +1,4 @@
import type { Basic } from '@/Interface';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import type { IConnections, IWorkflowDataProxyAdditionalKeys } from 'n8n-workflow';
import type { WorkflowObjectAccessors } from './workflow';
import type { IWorkflowDataProxyAdditionalKeys } from 'n8n-workflow';
type Range = { from: number; to: number };
@ -39,11 +36,7 @@ export namespace ColoringStateEffect {
*/
export interface ExpressionLocalResolveContext {
localResolve: true;
envVars: Record<string, Basic>;
additionalKeys: IWorkflowDataProxyAdditionalKeys;
workflow: WorkflowObjectAccessors;
connections: IConnections;
execution: IExecutionResponse | null;
nodeName: string;
/**
* Allowed to be undefined (e.g., trigger node, partial execution)

View File

@ -12,36 +12,42 @@ describe('Utils: Expressions', () => {
describe('stringifyExpressionResult()', () => {
it('should return empty string for non-critical errors', () => {
expect(
stringifyExpressionResult({
ok: false,
error: new ExpressionError('error message', { type: 'no_execution_data' }),
}),
stringifyExpressionResult(
{
ok: false,
error: new ExpressionError('error message', { type: 'no_execution_data' }),
},
{},
),
).toEqual('');
});
it('should return an error message for critical errors', () => {
expect(
stringifyExpressionResult({
ok: false,
error: new ExpressionError('error message', { type: 'no_input_connection' }),
}),
stringifyExpressionResult(
{
ok: false,
error: new ExpressionError('error message', { type: 'no_input_connection' }),
},
{},
),
).toEqual('[ERROR: No input connected]');
});
it('should return empty string when result is null', () => {
expect(stringifyExpressionResult({ ok: true, result: null })).toEqual('');
expect(stringifyExpressionResult({ ok: true, result: null }, {})).toEqual('');
});
it('should return NaN when result is NaN', () => {
expect(stringifyExpressionResult({ ok: true, result: NaN })).toEqual('NaN');
expect(stringifyExpressionResult({ ok: true, result: NaN }, {})).toEqual('NaN');
});
it('should return [empty] message when result is empty string', () => {
expect(stringifyExpressionResult({ ok: true, result: '' })).toEqual('[empty]');
expect(stringifyExpressionResult({ ok: true, result: '' }, {})).toEqual('[empty]');
});
it('should return the result when it is a string', () => {
expect(stringifyExpressionResult({ ok: true, result: 'foo' })).toEqual('foo');
expect(stringifyExpressionResult({ ok: true, result: 'foo' }, {})).toEqual('foo');
});
});

View File

@ -1,11 +1,12 @@
import { i18n } from '@n8n/i18n';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import type { ResolvableState } from '@/app/types/expressions';
import { ExpressionError, ExpressionParser, isExpression, type Result } from 'n8n-workflow';
import {
ExpressionError,
ExpressionParser,
isExpression,
type IPinData,
type Result,
} from 'n8n-workflow';
export { isExpression };
@ -79,7 +80,11 @@ export const getResolvableState = (error: unknown, ignoreError = false): Resolva
return 'invalid';
};
export const getExpressionErrorMessage = (error: Error, nodeHasRunData = false): string => {
export const getExpressionErrorMessage = (
error: Error,
pinData: IPinData,
nodeHasRunData = false,
): string => {
if (isNoExecDataExpressionError(error) || isPairedItemIntermediateNodesError(error)) {
return i18n.baseText('expressionModalInput.noExecutionData');
}
@ -100,11 +105,7 @@ export const getExpressionErrorMessage = (error: Error, nodeHasRunData = false):
if (isInvalidPairedItemError(error) || isNoPairedItemError(error)) {
const nodeCause = error.context.nodeCause as string;
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const isPinned = !!workflowDocumentStore.pinData?.[nodeCause];
const isPinned = !!pinData[nodeCause];
if (isPinned) {
return i18n.baseText('expressionModalInput.pairedItemInvalidPinnedError', {
@ -124,6 +125,7 @@ export const getExpressionErrorMessage = (error: Error, nodeHasRunData = false):
export const stringifyExpressionResult = (
result: Result<unknown, Error>,
pinData: IPinData,
nodeHasRunData = false,
): string => {
if (!result.ok) {
@ -131,7 +133,7 @@ export const stringifyExpressionResult = (
return '';
}
return `[${i18n.baseText('parameterInput.error')}: ${getExpressionErrorMessage(result.error, nodeHasRunData)}]`;
return `[${i18n.baseText('parameterInput.error')}: ${getExpressionErrorMessage(result.error, pinData, nodeHasRunData)}]`;
}
if (result.result === null) {

View File

@ -1,4 +1,4 @@
import { createTestNode, createTestWorkflow, createTestWorkflowObject } from '@/__tests__/mocks';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { createComponentRenderer } from '@/__tests__/render';
import InputPanel, { type Props } from './InputPanel.vue';
import { createTestingPinia } from '@pinia/testing';
@ -103,18 +103,12 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
});
}
const workflowObject = createTestWorkflowObject({
nodes,
connections,
});
return createComponentRenderer(InputPanel, {
props: {
pushRef: 'pushRef',
runIndex: 0,
currentNodeName: nodes[0].name,
activeNodeName: nodes[1].name,
workflowObject,
displayMode: 'schema',
focusedMappableInput: '',
isMappingOnboarded: false,

View File

@ -22,7 +22,6 @@ import {
NodeConnectionTypes,
NodeHelpers,
} from 'n8n-workflow';
import type { WorkflowObjectAccessors } from '@/app/types/workflow';
import { computed, ref, watch } from 'vue';
import InputNodeSelect from './InputNodeSelect.vue';
import NodeExecuteButton from '@/app/components/NodeExecuteButton.vue';
@ -42,7 +41,6 @@ type MappingMode = 'debugging' | 'mapping';
export type Props = {
runIndex: number;
workflowObject: WorkflowObjectAccessors;
pushRef: string;
activeNodeName: string;
currentNodeName?: string;
@ -116,6 +114,10 @@ const { runWorkflow } = useRunWorkflow({ router });
const { canReveal, isDynamicCredentials, revealData } = useExecutionRedaction();
const uiStore = useUIStore();
const workflowObject = computed(() =>
workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot(),
);
const openWorkflowSettings = () => {
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
};
@ -158,12 +160,12 @@ const isActiveNodeConfig = computed(() => {
let inputs = activeNodeType.value?.inputs ?? [];
let outputs = activeNodeType.value?.outputs ?? [];
if (props.workflowObject && activeNode.value) {
const node = props.workflowObject.getNode(activeNode.value.name);
if (activeNode.value) {
const node = workflowDocumentStore.value.getNodeByName(activeNode.value.name);
if (node && activeNodeType.value) {
inputs = NodeHelpers.getNodeInputs(props.workflowObject, node, activeNodeType.value);
outputs = NodeHelpers.getNodeOutputs(props.workflowObject, node, activeNodeType.value);
inputs = NodeHelpers.getNodeInputs(workflowObject.value, node, activeNodeType.value);
outputs = NodeHelpers.getNodeOutputs(workflowObject.value, node, activeNodeType.value);
}
}
@ -217,7 +219,7 @@ const isExecutingPrevious = computed(() => {
const rootNodesParents = computed(() => {
if (!rootNode.value) return [];
return props.workflowObject.getParentNodesByDepth(rootNode.value);
return workflowObject.value.getParentNodesByDepth(rootNode.value);
});
const currentNode = computed(() => {
@ -244,7 +246,7 @@ const parentNodes = computed(() => {
return [];
}
const parents = props.workflowObject
const parents = workflowObject.value
.getParentNodesByDepth(activeNode.value.name)
.filter((parent) => parent.name !== activeNode.value?.name);
return uniqBy(parents, (parent) => parent.name);
@ -271,7 +273,7 @@ const waitingMessage = computed(() => {
return waitingNodeTooltip(
workflowDocumentStore?.value?.getNodeByName(parentNode.name) ?? null,
props.workflowObject,
workflowObject.value,
parentRunData?.metadata,
);
});

View File

@ -6,7 +6,6 @@ import RunInfo from '@/features/ndv/runData/components/RunInfo.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { storeToRefs } from 'pinia';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import type { WorkflowObjectAccessors } from '@/app/types/workflow';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import RunDataAi from '@/features/ndv/runData/components/ai/RunDataAi.vue';
import { useNodeType } from '@/app/composables/useNodeType';
@ -29,6 +28,7 @@ import { N8nIcon, N8nRadioButtons, N8nSpinner, N8nText } from '@n8n/design-syste
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { useUIStore } from '@/app/stores/ui.store';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/app/constants';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
// Types
type RunDataRef = InstanceType<typeof RunData>;
@ -43,7 +43,6 @@ type OutputTypeKey = keyof typeof OUTPUT_TYPE;
type OutputType = (typeof OUTPUT_TYPE)[OutputTypeKey];
type Props = {
workflowObject: WorkflowObjectAccessors;
runIndex: number;
isReadOnly?: boolean;
linkedRuns?: boolean;
@ -83,6 +82,7 @@ const ndvStore = injectNDVStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const workflowDocumentStore = injectWorkflowDocumentStore();
const telemetry = useTelemetry();
const i18n = useI18n();
const { activeNode } = storeToRefs(ndvStore);
@ -111,6 +111,10 @@ const collapsingColumnName = ref<string | null>(null);
// Computed
const workflowObject = computed(() =>
workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot(),
);
const node = computed(() => {
return ndvStore.activeNode ?? undefined;
});
@ -127,7 +131,10 @@ const hasAiMetadata = computed(() => {
}
if (node.value) {
const connectedSubNodes = props.workflowObject.getParentNodes(node.value.name, 'ALL_NON_MAIN');
const connectedSubNodes = workflowDocumentStore.value.getParentNodes(
node.value.name,
'ALL_NON_MAIN',
);
const resultData = connectedSubNodes.map(workflowsStore.getWorkflowResultDataByNodeName);
return resultData && Array.isArray(resultData) && resultData.length > 0;
@ -218,7 +225,7 @@ const allToolsWereUnusedNotice = computed(() => {
// as it likely ends up unactionable noise to the user
if (pinnedData.hasData.value) return undefined;
const toolsAvailable = props.workflowObject.getParentNodes(
const toolsAvailable = workflowDocumentStore.value.getParentNodes(
node.value.name,
NodeConnectionTypes.AiTool,
1,

View File

@ -17,6 +17,7 @@ import { removeExpressionPrefix } from '@/app/utils/expressions';
import { propertyNameFromExpression } from '@/app/utils/mappingUtils';
import { N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { typeFromExpression } from '../../utils/assignmentCollection.utils';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
interface Props {
path: string;
@ -48,6 +49,7 @@ const i18n = useI18n();
const ndvStore = injectNDVStore();
const environmentsStore = useEnvironmentsStore();
const { binaryDataAccessTooltip } = useBinaryDataAccessTooltip();
const workflowDocumentStore = injectWorkflowDocumentStore();
const assignmentTypeToNodeProperty = (
type: string,
@ -141,7 +143,10 @@ const onValueDrop = async (droppedExpression: string) => {
}
const droppedValue = removeExpressionPrefix(droppedExpression);
assignment.value.type = await typeFromExpression(droppedValue);
assignment.value.type = await typeFromExpression(
droppedValue,
workflowDocumentStore.value.documentId,
);
if (!assignment.value.name) {
assignment.value.name = propertyNameFromExpression(droppedValue);

View File

@ -22,6 +22,7 @@ import { ExpressionLocalResolveContextSymbol } from '@/app/constants';
import { useExperimentalNdvStore } from '@/features/workflows/canvas/experimental/experimentalNdv.store';
import { N8nInputLabel } from '@n8n/design-system';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
interface Props {
parameter: INodeProperties;
value: AssignmentCollectionValue;
@ -60,6 +61,7 @@ const state = reactive<{ paramValue: AssignmentCollectionValue }>({
paramValue: createParamValue(props.value),
});
const workflowDocumentStore = injectWorkflowDocumentStore();
const ndvStore = injectNDVStore();
const experimentalNdvStore = useExperimentalNdvStore();
const { callDebounced } = useDebounce();
@ -119,7 +121,9 @@ function addAssignment(): void {
}
async function dropAssignment(expression: string): Promise<void> {
const type = props.defaultType ?? (await typeFromExpression(expression));
const type =
props.defaultType ??
(await typeFromExpression(expression, workflowDocumentStore.value.documentId));
state.paramValue.assignments.push({
id: crypto.randomUUID(),
name: propertyNameFromExpression(expression),
@ -181,7 +185,6 @@ function optionSelected(action: string) {
node &&
expressionLocalResolveCtx?.inputNode
"
:workflow="expressionLocalResolveCtx.workflow"
:node="node"
:input-node-name="expressionLocalResolveCtx.inputNode.name"
:reference="dropAreaContainer?.$el"

View File

@ -17,12 +17,16 @@ vi.mock('@n8n/stores/useRootStore');
vi.mock('@/features/ai/assistant/assistant.api');
vi.mock('@/app/stores/workflowDocument.store', async () => {
const actual = await vi.importActual('@/app/stores/workflowDocument.store');
const { shallowRef } = await import('vue');
const mockStore = {
getParentNodesByDepth: vi.fn().mockReturnValue([]),
getNodeByName: vi.fn().mockReturnValue(null),
};
return {
...actual,
useWorkflowDocumentStore: vi.fn(() => ({
getParentNodesByDepth: vi.fn().mockReturnValue([]),
})),
useWorkflowDocumentStore: vi.fn(() => mockStore),
createWorkflowDocumentId: vi.fn().mockReturnValue('test-id'),
injectWorkflowDocumentStore: vi.fn(() => shallowRef(mockStore)),
};
});
vi.mock('@n8n/i18n', async (importOriginal) => ({

View File

@ -17,6 +17,7 @@ import { useTelemetry } from '@/app/composables/useTelemetry';
import DraggableTarget from '@/app/components/DraggableTarget.vue';
import { propertyNameFromExpression } from '@/app/utils/mappingUtils';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
const emit = defineEmits<{
@ -32,6 +33,8 @@ export type Props = {
const props = defineProps<Props>();
const ndvStore = injectNDVStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const activeNode = computed(() => ndvStore.activeNode);
const i18n = useI18n();
@ -106,6 +109,7 @@ async function onSubmit() {
const updateInformation = await generateCodeForAiTransform(
prompt.value,
getPath(target as string),
workflowDocumentStore.value.documentId,
5,
);
if (!updateInformation) return;
@ -158,7 +162,7 @@ function onPromptInput(inputValue: string) {
}
onMounted(() => {
parentNodes.value = getParentNodes();
parentNodes.value = getParentNodes(workflowDocumentStore.value.documentId);
});
function cleanTextareaRowsData() {

View File

@ -27,6 +27,7 @@ import type { ConditionResult } from './types';
import { useDebounce } from '@/app/composables/useDebounce';
import { N8nIcon, N8nIconButton, N8nTooltip } from '@n8n/design-system';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
interface Props {
path: string;
condition: FilterConditionValue;
@ -55,6 +56,7 @@ const emit = defineEmits<{
const i18n = useI18n();
const { debounce } = useDebounce();
const workflowDocumentStore = injectWorkflowDocumentStore();
const condition = ref<FilterConditionValue>(props.condition);
@ -85,6 +87,7 @@ const conditionResult = computedAsync<ConditionResult>(
return await resolveCondition({
condition: currentCondition,
options: currentOptions,
workflowDocumentId: workflowDocumentStore.value.documentId,
});
},
{ status: 'resolve_error' },

View File

@ -25,6 +25,7 @@ import { resolveParameter } from '@/app/composables/useWorkflowHelpers';
import Draggable from 'vuedraggable';
import { N8nButton, N8nInputLabel } from '@n8n/design-system';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
interface Props {
parameter: INodeProperties;
value: FilterValue;
@ -53,6 +54,7 @@ const emit = defineEmits<{
const i18n = useI18n();
const ndvStore = injectNDVStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const { debounce } = useDebounce();
const debouncedEmitChange = debounce(emitChange, { debounceTime: 1000 });
@ -114,7 +116,10 @@ watchEffect(async () => {
try {
newOptions = {
...DEFAULT_FILTER_OPTIONS,
...(await resolveParameter(typeOptions as unknown as NodeParameterValue)),
...(await resolveParameter(
typeOptions as unknown as NodeParameterValue,
workflowDocumentStore.value.documentId,
)),
};
} catch {
// Keep default options

View File

@ -14,6 +14,7 @@ import {
import { OPERATORS_BY_ID, type FilterOperatorId } from './constants';
import type { ConditionResult, FilterOperator } from './types';
import { DateTime } from 'luxon';
import type { WorkflowDocumentId } from '@/app/stores/workflowDocument.store';
export const getFilterOperator = (key: string) =>
OPERATORS_BY_ID[key as FilterOperatorId] as FilterOperator;
@ -81,14 +82,17 @@ export const resolveCondition = async ({
condition,
options,
index = 0,
workflowDocumentId,
}: {
condition: FilterConditionValue;
options: FilterOptionsValue;
index?: number;
workflowDocumentId: WorkflowDocumentId;
}): Promise<ConditionResult> => {
try {
const resolved = (await resolveParameter(
condition as unknown as NodeParameterValue,
workflowDocumentId,
)) as FilterConditionValue;
if (resolved.leftValue === undefined || resolved.rightValue === undefined) {

View File

@ -15,12 +15,10 @@ import { createEventBus } from '@n8n/utils/event-bus';
import {
createTestExpressionLocalResolveContext,
createMockEnterpriseSettings,
createTestNode,
createTestWorkflowObject,
createTestNodeProperties,
} from '@/__tests__/mocks';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { NodeConnectionTypes, type INodeParameterResourceLocator } from 'n8n-workflow';
import { type INodeParameterResourceLocator } from 'n8n-workflow';
import type { IWorkflowDb, WorkflowListResource } from '@/Interface';
import { mock } from 'vitest-mock-extended';
import { ExpressionLocalResolveContextSymbol } from '@/app/constants';
@ -723,16 +721,7 @@ describe('ParameterInput.vue', () => {
});
describe('data mapper', () => {
const workflow = createTestWorkflowObject({
nodes: [createTestNode({ name: 'n0' }), createTestNode({ name: 'n1' })],
connections: {
n1: {
[NodeConnectionTypes.Main]: [[{ node: 'n0', index: 0, type: NodeConnectionTypes.Main }]],
},
},
});
const ctx = createTestExpressionLocalResolveContext({
workflow,
nodeName: 'n0',
inputNode: { name: 'n1', runIndex: 0, branchIndex: 0 },
});

View File

@ -236,9 +236,9 @@ const isFocused = ref(false);
const isSwitchingMode = ref(false);
const node = computed(() => {
const contextNode = expressionLocalResolveCtx?.value?.workflow.getNode(
expressionLocalResolveCtx.value.nodeName,
);
const contextNode =
expressionLocalResolveCtx?.value &&
workflowDocumentStore.value.getNodeByName(expressionLocalResolveCtx.value.nodeName);
return contextNode ?? ndvStore.activeNode ?? undefined;
});
const nodeType = computed(
@ -492,7 +492,10 @@ const dependentParametersValues = computedAsync(async () => {
// Get the resolved parameter values of the current node
const currentNodeParameters = node.value?.parameters;
try {
const resolvedNodeParameters = await workflowHelpers.resolveParameter(currentNodeParameters);
const resolvedNodeParameters = await workflowHelpers.resolveParameter(
currentNodeParameters,
workflowDocumentStore.value.documentId,
);
const returnValues: string[] = [];
for (let parameterPath of loadOptionsDependsOn) {
@ -813,6 +816,7 @@ async function loadRemoteParameterOptions() {
const resolvedNodeParameters = (await workflowHelpers.resolveRequiredParameters(
props.parameter,
currentNodeParameters,
workflowDocumentStore.value.documentId,
expressionLocalResolveCtx?.value ?? {},
)) as INodeParameters;
const loadOptionsMethod = getTypeOption('loadOptionsMethod');
@ -1431,7 +1435,6 @@ onUpdated(async () => {
<ExperimentalEmbeddedNdvMapper
v-if="wrapper && isMapperAvailable && node && expressionLocalResolveCtx?.inputNode"
:workflow="expressionLocalResolveCtx.workflow"
:node="node"
:input-node-name="expressionLocalResolveCtx.inputNode.name"
:reference="wrapper"

View File

@ -38,6 +38,7 @@ import { ChatHubToolContextKey, ExpressionLocalResolveContextSymbol } from '@/ap
import { N8nInputLabel } from '@n8n/design-system';
import { useCollectionOverhaul } from '@/app/composables/useCollectionOverhaul';
import type { ParameterOptionsOverrides } from '@/features/ndv/shared/ndv.utils';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
type Props = {
parameter: INodeProperties;
@ -86,6 +87,7 @@ const forceShowExpression = ref(false);
const wrapperHovered = ref(false);
const ndvStore = injectNDVStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const telemetry = useTelemetry();
const { isEnabled: isCollectionOverhaulEnabled } = useCollectionOverhaul();
@ -95,7 +97,7 @@ const activeNode = computed(() => {
const ctx = expressionLocalResolveCtx?.value;
if (ctx) {
return ctx.workflow.getNode(ctx.nodeName);
return workflowDocumentStore.value.getNodeByName(ctx.nodeName);
}
return ndvStore.activeNode;

View File

@ -516,7 +516,10 @@ async function getDependentParametersValues(parameter: INodeProperties): Promise
// Get the resolved parameter values of the current node
const currentNodeParameters = ndvStore.activeNode?.parameters;
try {
const resolvedNodeParameters = await workflowHelpers.resolveParameter(currentNodeParameters);
const resolvedNodeParameters = await workflowHelpers.resolveParameter(
currentNodeParameters,
workflowDocumentStore.value.documentId,
);
const returnValues: string[] = [];
for (let parameterPath of loadOptionsDependsOn) {

View File

@ -952,6 +952,7 @@ describe('ResourceLocator', () => {
await waitFor(() => {
expect(mockResolveRequiredParameters).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
testContext,
@ -972,6 +973,7 @@ describe('ResourceLocator', () => {
await waitFor(() => {
expect(mockResolveRequiredParameters).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
{},
@ -1009,6 +1011,7 @@ describe('ResourceLocator', () => {
await waitFor(() => {
expect(mockResolveRequiredParameters).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
testContext,

View File

@ -431,6 +431,7 @@ const handleAddResourceClick = async () => {
const resolvedNodeParameters = await workflowHelpers.resolveRequiredParameters(
props.parameter,
currentRequestParams.value.parameters,
workflowDocumentStore.value.documentId,
expressionLocalResolveCtx?.value ?? {},
);
@ -821,6 +822,7 @@ async function loadResources() {
const resolvedNodeParameters = (await workflowHelpers.resolveRequiredParameters(
props.parameter,
params.parameters,
workflowDocumentStore.value.documentId,
expressionLocalResolveCtx?.value ?? {},
)) as INodeParameters;
const loadOptionsMethod = getPropertyArgument(currentMode.value, 'searchListMethod') as string;

View File

@ -34,6 +34,7 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
import ParameterInputFull from '../ParameterInputFull.vue';
import { N8nButton, N8nCallout, N8nIcon, N8nNotice, N8nText } from '@n8n/design-system';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
type Props = {
parameter: INodeProperties;
node: INode | null;
@ -51,6 +52,7 @@ const ndvStore = injectNDVStore();
const workflowsStore = useWorkflowsStore();
const projectsStore = useProjectsStore();
const expressionLocalResolveCtx = inject(ExpressionLocalResolveContextSymbol, undefined);
const workflowDocumentStore = injectWorkflowDocumentStore();
const props = withDefaults(defineProps<Props>(), {
teleported: true,
@ -334,6 +336,7 @@ const createRequestParams = async (methodName: string) => {
currentNodeParameters: (await resolveRequiredParameters(
props.parameter,
props.node.parameters,
workflowDocumentStore.value.documentId,
expressionLocalResolveCtx?.value ?? {},
)) as INodeParameters,
path: props.path,

View File

@ -3,6 +3,7 @@ import type { AssignmentValue, IDataObject } from 'n8n-workflow';
import { resolveParameter } from '@/app/composables/useWorkflowHelpers';
import { v4 as uuid } from 'uuid';
import { isBinaryLike } from '@/app/utils/typeGuards';
import type { WorkflowDocumentId } from '@/app/stores/workflowDocument.store';
export function inferAssignmentType(value: unknown): string {
if (typeof value === 'boolean') return 'boolean';
@ -14,9 +15,12 @@ export function inferAssignmentType(value: unknown): string {
return 'string';
}
export async function typeFromExpression(expression: string): Promise<string> {
export async function typeFromExpression(
expression: string,
workflowDocumentId: WorkflowDocumentId,
): Promise<string> {
try {
const resolved = await resolveParameter(`=${expression}`);
const resolved = await resolveParameter(`=${expression}`, workflowDocumentId);
return inferAssignmentType(resolved);
} catch {
return 'string';

View File

@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia';
import { generateCodeForPrompt } from '@/features/ai/assistant/assistant.api';
import type { AskAiRequest } from '@/features/ai/assistant/assistant.types';
import type { Schema } from '@/Interface';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils');
@ -55,7 +56,12 @@ describe('generateCodeForAiTransform - Retry Tests', () => {
.mockRejectedValueOnce(new Error('First attempt failed'))
.mockResolvedValueOnce({ code: mockGeneratedCode });
const result = await generateCodeForAiTransform('test prompt', 'test/path', 2);
const result = await generateCodeForAiTransform(
'test prompt',
'test/path',
createWorkflowDocumentId(''),
2,
);
expect(result).toEqual({
name: 'test/path',
@ -67,9 +73,9 @@ describe('generateCodeForAiTransform - Retry Tests', () => {
it('should exhaust retries and throw an error', async () => {
vi.mocked(generateCodeForPrompt).mockRejectedValue(new Error('All attempts failed'));
await expect(generateCodeForAiTransform('test prompt', 'test/path', 3)).rejects.toThrow(
'All attempts failed',
);
await expect(
generateCodeForAiTransform('test prompt', 'test/path', createWorkflowDocumentId(''), 3),
).rejects.toThrow('All attempts failed');
expect(generateCodeForPrompt).toHaveBeenCalledTimes(3);
});
@ -78,7 +84,11 @@ describe('generateCodeForAiTransform - Retry Tests', () => {
const mockGeneratedCode = 'const example = "no retries needed";';
vi.mocked(generateCodeForPrompt).mockResolvedValue({ code: mockGeneratedCode });
const result = await generateCodeForAiTransform('test prompt', 'test/path');
const result = await generateCodeForAiTransform(
'test prompt',
'test/path',
createWorkflowDocumentId(''),
);
expect(result).toEqual({
name: 'test/path',

View File

@ -1,6 +1,5 @@
import type { Schema } from '@/Interface';
import { ApplicationError, type INodeExecutionData } from 'n8n-workflow';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { useDataSchema } from '@/app/composables/useDataSchema';
import { executionDataToJson } from '@/app/utils/nodeTypesUtils';
@ -12,8 +11,8 @@ import { format } from 'prettier';
import jsParser from 'prettier/plugins/babel';
import * as estree from 'prettier/plugins/estree';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
export type TextareaRowData = {
@ -21,10 +20,9 @@ export type TextareaRowData = {
linesToRowsMap: number[][];
};
export function getParentNodes() {
export function getParentNodes(workflowDocumentId: WorkflowDocumentId) {
const workflowDocumentStore = useWorkflowDocumentStore(workflowDocumentId);
const activeNode = useNDVStore().activeNode;
const { workflowId } = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
if (!activeNode) return [];
@ -37,8 +35,8 @@ export function getParentNodes() {
.filter((n) => n !== null);
}
export function getSchemas() {
const parentNodes = getParentNodes();
export function getSchemas(workflowDocumentId: WorkflowDocumentId) {
const parentNodes = getParentNodes(workflowDocumentId);
const parentNodesNames = parentNodes.map((node) => node?.name);
const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema();
const parentNodesSchemas: Array<{ nodeName: string; schema: Schema }> = parentNodes
@ -185,8 +183,13 @@ export function reducePayloadSizeOrThrow(
if (remainingTokensToReduce > 0) throw error;
}
export async function generateCodeForAiTransform(prompt: string, path: string, retries = 1) {
const schemas = getSchemas();
export async function generateCodeForAiTransform(
prompt: string,
path: string,
workflowDocumentId: WorkflowDocumentId,
retries = 1,
) {
const schemas = getSchemas(workflowDocumentId);
const payload: AskAiRequest.RequestPayload = {
question: prompt,

View File

@ -134,7 +134,7 @@ const workflowRunData = computed(() => {
const parentNodes = computed(() => {
if (activeNode.value) {
return workflowObject.value?.getParentNodesByDepth(activeNode.value.name, 1) ?? [];
return workflowDocumentStore.value.getParentNodesByDepth(activeNode.value.name, 1) ?? [];
}
return [];
});
@ -170,7 +170,7 @@ const inputNodeName = computed<string | undefined>(() => {
// For sub-nodes, we need to get their connected output node to determine the input
// because sub-nodes use specialized outputs (e.g. NodeConnectionTypes.AiTool)
// instead of the standard Main output type
const connectedOutputNode = workflowObject.value?.getChildNodes(
const connectedOutputNode = workflowDocumentStore.value.getChildNodes(
activeNode.value.name,
'ALL_NON_MAIN',
)?.[0];
@ -247,7 +247,7 @@ const maxInputRun = computed(() => {
return 0;
}
const workflowNode = workflowObject.value?.getNode(activeNode.value.name);
const workflowNode = workflowDocumentStore.value.getNodeByName(activeNode.value.name);
if (!workflowNode || !activeNodeType.value || !workflowObject.value) {
return 0;
@ -746,8 +746,7 @@ onBeforeUnmount(() => {
@activate="onWorkflowActivate"
/>
<InputPanel
v-else-if="!isTriggerNode && workflowObject"
:workflow-object="workflowObject"
v-else-if="!isTriggerNode"
:can-link-runs="canLinkRuns"
:run-index="inputRun"
:linked-runs="linked"
@ -775,9 +774,7 @@ onBeforeUnmount(() => {
</template>
<template #output>
<OutputPanel
v-if="workflowObject"
data-test-id="output-panel"
:workflow-object="workflowObject"
:can-link-runs="canLinkRuns"
:run-index="outputRun"
:linked-runs="linked"

View File

@ -134,8 +134,8 @@ const workflowRunData = computed(() => {
const parentNodes = computed(() => {
if (activeNode.value) {
return (
workflowObject.value
?.getParentNodesByDepth(activeNode.value.name, 1)
workflowDocumentStore.value
.getParentNodesByDepth(activeNode.value.name, 1)
.map(({ name }) => name) || []
);
} else {
@ -174,7 +174,7 @@ const inputNodeName = computed<string | undefined>(() => {
// For sub-nodes, we need to get their connected output node to determine the input
// because sub-nodes use specialized outputs (e.g. NodeConnectionTypes.AiTool)
// instead of the standard Main output type
const connectedOutputNode = workflowObject.value?.getChildNodes(
const connectedOutputNode = workflowDocumentStore.value.getChildNodes(
activeNode.value.name,
'ALL_NON_MAIN',
)?.[0];
@ -251,7 +251,7 @@ const maxInputRun = computed(() => {
return 0;
}
const workflowNode = workflowObject.value?.getNode(activeNode.value.name);
const workflowNode = workflowDocumentStore.value.getNodeByName(activeNode.value.name);
if (!workflowNode || !activeNodeType.value || !workflowObject.value) {
return 0;
@ -759,7 +759,6 @@ onBeforeUnmount(() => {
/>
<InputPanel
v-else-if="!isTriggerNode && workflowObject"
:workflow-object="workflowObject"
:can-link-runs="canLinkRuns"
:run-index="inputRun"
:linked-runs="linked"
@ -829,9 +828,7 @@ onBeforeUnmount(() => {
:style="{ width: `${panelWidthPercentage.right}%` }"
>
<OutputPanel
v-if="workflowObject"
data-test-id="output-panel"
:workflow-object="workflowObject"
:can-link-runs="canLinkRuns"
:run-index="outputRun"
:linked-runs="linked"

View File

@ -48,9 +48,13 @@ import { useI18n } from '@n8n/i18n';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useAutocompleteTelemetry } from '@/app/composables/useAutocompleteTelemetry';
import { ignoreUpdateAnnotation } from '@/app/utils/forceParse';
import { TARGET_NODE_PARAMETER_FACET } from '../plugins/codemirror/completions/constants';
import {
TARGET_NODE_PARAMETER_FACET,
WORKFLOW_DOCUMENT_FACET,
} from '../plugins/codemirror/completions/constants';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { isEventTargetContainedBy } from '@/app/utils/htmlUtils';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
export const useExpressionEditor = ({
editorRef,
@ -76,6 +80,7 @@ export const useExpressionEditor = ({
onChange?: (viewUpdate: ViewUpdate) => void;
}) => {
const ndvStore = useNDVStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const workflowsStore = useWorkflowsStore();
const workflowHelpers = useWorkflowHelpers();
const { isMacOs } = useDeviceSupport();
@ -241,6 +246,7 @@ export const useExpressionEditor = ({
? { nodeName: expressionLocalResolveContext.value.nodeName, parameterPath: '' }
: toValue(targetNodeParameterContext),
),
WORKFLOW_DOCUMENT_FACET.of(workflowDocumentStore.value.documentId),
customExtensions.value.of(toValue(extensions)),
readOnlyExtensions.value.of([EditorState.readOnly.of(toValue(isReadOnly))]),
telemetryExtensions.value.of([]),
@ -388,7 +394,7 @@ export const useExpressionEditor = ({
!!workflowsStore.workflowExecutionData?.data?.resultData?.runData[
ndvStore.activeNode?.name ?? ''
];
result.resolved = `[${getExpressionErrorMessage(error, hasRunData)}]`;
result.resolved = `[${getExpressionErrorMessage(error, workflowDocumentStore.value.getPinDataSnapshot(), hasRunData)}]`;
result.error = true;
result.fullError = error;
}

View File

@ -14,7 +14,12 @@ import type { CompletionSource, CompletionResult } from '@codemirror/autocomplet
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { n8nLang } from '@/features/shared/editors/plugins/codemirror/n8nLang';
import { LUXON_RECOMMENDED_OPTIONS, STRING_RECOMMENDED_OPTIONS } from './constants';
import {
LUXON_RECOMMENDED_OPTIONS,
STRING_RECOMMENDED_OPTIONS,
WORKFLOW_DOCUMENT_FACET,
} from './constants';
import uniqBy from 'lodash/uniqBy';
beforeEach(async () => {
@ -107,7 +112,7 @@ export async function completions(docWithCursor: string, explicit = false) {
const state = EditorState.create({
doc,
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
extensions: [n8nLang(), WORKFLOW_DOCUMENT_FACET.of('test@latest')],
});
const context = new CompletionContext(state, cursorPosition, explicit);

View File

@ -2,7 +2,7 @@ import { prefixMatch, longestCommonPrefix, resolveAutocompleteExpression } from
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { Resolved } from './types';
import { escapeMappingString } from '@/app/utils/mappingUtils';
import { TARGET_NODE_PARAMETER_FACET } from './constants';
import { TARGET_NODE_PARAMETER_FACET, WORKFLOW_DOCUMENT_FACET } from './constants';
/**
* Resolution-based completions offered at the start of bracket access notation.
@ -18,6 +18,7 @@ export async function bracketAccessCompletions(
context: CompletionContext,
): Promise<CompletionResult | null> {
const targetNodeParameterContext = context.state.facet(TARGET_NODE_PARAMETER_FACET);
const workflowDocumentId = context.state.facet(WORKFLOW_DOCUMENT_FACET);
const word = context.matchBefore(/\$[\S\s]*\[.*/);
if (!word) return null;
@ -36,6 +37,7 @@ export async function bracketAccessCompletions(
try {
resolved = await resolveAutocompleteExpression(
`={{ ${base} }}`,
workflowDocumentId,
targetNodeParameterContext?.nodeName,
);
} catch {

View File

@ -28,7 +28,9 @@ import {
METHODS_SECTION,
RECOMMENDED_SECTION,
STRING_RECOMMENDED_OPTIONS,
WORKFLOW_DOCUMENT_FACET,
} from './constants';
import set from 'lodash/set';
import uniqBy from 'lodash/uniqBy';
import { mockNodes } from '@/__tests__/mocks';
@ -45,7 +47,7 @@ export async function completions(docWithCursor: string, explicit = false) {
const state = EditorState.create({
doc,
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
extensions: [n8nLang(), WORKFLOW_DOCUMENT_FACET.of('test@latest')],
});
const context = new CompletionContext(state, cursorPosition, explicit);

View File

@ -4,6 +4,7 @@ import { withSectionHeader } from './utils';
import { createInfoBoxRenderer } from './infoBoxRenderer';
import { Facet } from '@codemirror/state';
import type { TargetNodeParameterContext } from '@/Interface';
import type { WorkflowDocumentId } from '@/app/stores/workflowDocument.store';
export const FIELDS_SECTION: CompletionSection = withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.fields'),
@ -465,3 +466,7 @@ export const TARGET_NODE_PARAMETER_FACET = Facet.define<
>({
combine: (values) => values[0],
});
export const WORKFLOW_DOCUMENT_FACET = Facet.define<WorkflowDocumentId, WorkflowDocumentId>({
combine: (values) => values[0],
});

View File

@ -35,6 +35,7 @@ import {
STRING_SECTIONS,
TARGET_NODE_PARAMETER_FACET,
VARIABLE_SECTIONS,
WORKFLOW_DOCUMENT_FACET,
} from './constants';
import { createInfoBoxRenderer } from './infoBoxRenderer';
import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs';
@ -66,6 +67,7 @@ import { javascriptLanguage } from '@codemirror/lang-javascript';
import { isPairedItemIntermediateNodesError } from '@/app/utils/expressions';
import type { TargetNodeParameterContext } from '@/Interface';
import { useSettingsStore } from '@/app/stores/settings.store';
import type { WorkflowDocumentId } from '@/app/stores/workflowDocument.store';
/**
* Resolution-based completions offered according to datatype.
@ -74,6 +76,7 @@ export async function datatypeCompletions(
context: CompletionContext,
): Promise<CompletionResult | null> {
const targetNodeParameterContext = context.state.facet(TARGET_NODE_PARAMETER_FACET);
const workflowDocumentId = context.state.facet(WORKFLOW_DOCUMENT_FACET);
const word = context.matchBefore(DATATYPE_REGEX);
if (!word) return null;
@ -102,6 +105,7 @@ export async function datatypeCompletions(
try {
resolved = await resolveAutocompleteExpression(
`={{ ${base} }}`,
workflowDocumentId,
targetNodeParameterContext?.nodeName,
);
} catch (error) {
@ -113,6 +117,7 @@ export async function datatypeCompletions(
try {
resolved = await resolveAutocompleteExpression(
`={{ ${expressionWithFirstItem(syntaxTree, base)} }}`,
workflowDocumentId,
targetNodeParameterContext?.nodeName,
);
} catch {
@ -123,7 +128,9 @@ export async function datatypeCompletions(
if (resolved === null) return null;
try {
options = (await datatypeOptions({ resolved, base, tail })).map(stripExcessParens(context));
options = (await datatypeOptions({ resolved, base, tail }, workflowDocumentId)).map(
stripExcessParens(context),
);
} catch {
options = [];
}
@ -138,7 +145,11 @@ export async function datatypeCompletions(
// When autocomplete is explicitely opened (by Ctrl+Space or programatically), add completions for the current word with '.' prefix
// example: {{ $json.str| }} -> ['length', 'includes()'...] (would usually need a '.' suffix)
if (context.explicit && !word.text.endsWith('.') && options.length === 0) {
options = await explicitDataTypeOptions(word.text, targetNodeParameterContext);
options = await explicitDataTypeOptions(
word.text,
workflowDocumentId,
targetNodeParameterContext,
);
from = word.to;
}
@ -202,25 +213,33 @@ function filterOptions(options: AliasCompletion[], tail: string): AliasCompletio
async function explicitDataTypeOptions(
expression: string,
workflowDocumentId: WorkflowDocumentId,
targetNodeParameterContext?: TargetNodeParameterContext,
): Promise<AliasCompletion[]> {
try {
const resolved = await resolveAutocompleteExpression(
`={{ ${expression} }}`,
workflowDocumentId,
targetNodeParameterContext?.nodeName,
);
return await datatypeOptions({
resolved,
base: expression,
tail: '',
transformLabel: (label) => '.' + label,
});
return await datatypeOptions(
{
resolved,
base: expression,
tail: '',
transformLabel: (label) => '.' + label,
},
workflowDocumentId,
);
} catch {
return [];
}
}
async function datatypeOptions(input: AutocompleteInput): Promise<AliasCompletion[]> {
async function datatypeOptions(
input: AutocompleteInput,
workflowDocumentId: WorkflowDocumentId,
): Promise<AliasCompletion[]> {
const { resolved } = input;
if (resolved === null) return [];
@ -256,7 +275,7 @@ async function datatypeOptions(input: AutocompleteInput): Promise<AliasCompletio
}
if (typeof resolved === 'object') {
return await objectOptions(input as AutocompleteInput<IDataObject>);
return await objectOptions(input as AutocompleteInput<IDataObject>, workflowDocumentId);
}
return [];
@ -401,6 +420,7 @@ const createCompletionOption = ({
const customObjectOptions = async (
input: AutocompleteInput<IDataObject>,
workflowDocumentId: WorkflowDocumentId,
): Promise<Completion[]> => {
const { base, resolved } = input;
@ -413,11 +433,11 @@ const customObjectOptions = async (
} else if (base === '$workflow') {
return workflowOptions();
} else if (base === '$input') {
return await inputOptions(base);
return await inputOptions(base, workflowDocumentId);
} else if (base === '$prevNode') {
return prevNodeOptions();
} else if (/^\$\(['"][\S\s]+['"]\)$/.test(base)) {
return await nodeRefOptions(base);
return await nodeRefOptions(base, workflowDocumentId);
} else if (base === '$response') {
return responseOptions();
} else if (isItem(input)) {
@ -429,11 +449,14 @@ const customObjectOptions = async (
return [];
};
const objectOptions = async (input: AutocompleteInput<IDataObject>): Promise<Completion[]> => {
const objectOptions = async (
input: AutocompleteInput<IDataObject>,
workflowDocumentId: WorkflowDocumentId,
): Promise<Completion[]> => {
const { base, resolved, transformLabel = (label) => label } = input;
const SKIP = new Set(['__ob__', 'pairedItem']);
if (isSplitInBatchesAbsent()) SKIP.add('context');
if (isSplitInBatchesAbsent(workflowDocumentId)) SKIP.add('context');
let rawKeys = Object.keys(resolved);
@ -442,7 +465,7 @@ const objectOptions = async (input: AutocompleteInput<IDataObject>): Promise<Com
rawKeys = Object.keys(descriptors).sort((a, b) => a.localeCompare(b));
}
const customOptions = await customObjectOptions(input);
const customOptions = await customObjectOptions(input, workflowDocumentId);
if (customOptions.length > 0) {
// Only return completions that are present in the resolved data
return customOptions.filter((option) => option.label in resolved);
@ -958,7 +981,7 @@ export const customDataOptions = () => {
].map((doc) => createCompletionOption({ name: doc.name, doc, isFunction: true }));
};
export const nodeRefOptions = async (base: string) => {
export const nodeRefOptions = async (base: string, workflowDocumentId: WorkflowDocumentId) => {
const itemArgs = [
{
name: 'branchIndex',
@ -1047,7 +1070,7 @@ export const nodeRefOptions = async (base: string) => {
},
];
const noParams = await hasNoParams(base);
const noParams = await hasNoParams(base, workflowDocumentId);
return applySections({
options: options
.filter((option) => !(option.doc.name === 'params' && noParams))
@ -1057,7 +1080,7 @@ export const nodeRefOptions = async (base: string) => {
});
};
export const inputOptions = async (base: string) => {
export const inputOptions = async (base: string, workflowDocumentId: WorkflowDocumentId) => {
const itemArgs = [
{
name: 'branchIndex',
@ -1120,7 +1143,7 @@ export const inputOptions = async (base: string) => {
},
];
const noParams = await hasNoParams(base);
const noParams = await hasNoParams(base, workflowDocumentId);
return applySections({
options: options
.filter((option) => !(option.doc.name === 'params' && noParams))

View File

@ -19,6 +19,7 @@ import {
RECOMMENDED_SECTION,
ROOT_DOLLAR_COMPLETIONS,
TARGET_NODE_PARAMETER_FACET,
WORKFLOW_DOCUMENT_FACET,
} from './constants';
import { createInfoBoxRenderer } from './infoBoxRenderer';
@ -121,27 +122,30 @@ export async function dollarOptions(context: CompletionContext): Promise<Complet
}
const targetNodeParameterContext = context.state.facet(TARGET_NODE_PARAMETER_FACET);
const workflowDocumentId = context.state.facet(WORKFLOW_DOCUMENT_FACET);
if (!hasActiveNode(targetNodeParameterContext)) {
if (!hasActiveNode(workflowDocumentId, targetNodeParameterContext)) {
return [];
}
if (await receivesNoBinaryData(targetNodeParameterContext?.nodeName)) SKIP.add('$binary');
if (await receivesNoBinaryData(workflowDocumentId, targetNodeParameterContext?.nodeName))
SKIP.add('$binary');
const previousNodesCompletions = autocompletableNodeNames(targetNodeParameterContext).map(
(nodeName) => {
const label = `$('${escapeMappingString(nodeName)}')`;
return {
label,
info: createInfoBoxRenderer({
name: label,
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
}),
section: PREVIOUS_NODES_SECTION,
};
},
);
const previousNodesCompletions = autocompletableNodeNames(
workflowDocumentId,
targetNodeParameterContext,
).map((nodeName) => {
const label = `$('${escapeMappingString(nodeName)}')`;
return {
label,
info: createInfoBoxRenderer({
name: label,
returnType: 'Object',
description: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
}),
section: PREVIOUS_NODES_SECTION,
};
});
return recommendedCompletions
.concat(ROOT_DOLLAR_COMPLETIONS)

View File

@ -12,6 +12,7 @@ import type { CompletionSource, CompletionResult } from '@codemirror/autocomplet
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { n8nLang } from '@/features/shared/editors/plugins/codemirror/n8nLang';
import { WORKFLOW_DOCUMENT_FACET } from './constants';
beforeEach(async () => {
setActivePinia(createTestingPinia());
@ -28,7 +29,7 @@ export async function completions(docWithCursor: string, explicit = false) {
const state = EditorState.create({
doc,
selection: { anchor: cursorPosition },
extensions: [n8nLang()],
extensions: [n8nLang(), WORKFLOW_DOCUMENT_FACET.of('test@latest')],
});
const context = new CompletionContext(state, cursorPosition, explicit);

View File

@ -16,6 +16,7 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import type { WorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
vi.mock('@/app/composables/useWorkflowHelpers', () => ({
useWorkflowHelpers: vi.fn().mockReturnValue({
@ -32,7 +33,7 @@ const { mockWorkflowDocumentStore } = vi.hoisted(() => ({
settings: {},
getPinDataSnapshot: () => ({}),
workflowTriggerNodes: [],
},
} as Partial<WorkflowDocumentStore> as WorkflowDocumentStore,
}));
vi.mock('@/app/stores/workflowDocument.store', () => ({
@ -113,16 +114,16 @@ describe('completion utils', () => {
];
mockedStore(useWorkflowsStore);
mockWorkflowDocumentStore.getChildNodes.mockReturnValue([]);
mockWorkflowDocumentStore.getParentNodesByDepth.mockReturnValue([
{ name: 'Node 2', depth: 1 },
{ name: 'Node 1', depth: 2 },
vi.mocked(mockWorkflowDocumentStore.getChildNodes).mockReturnValue([]);
vi.mocked(mockWorkflowDocumentStore.getParentNodesByDepth).mockReturnValue([
{ name: 'Node 2', depth: 1, indicies: [] },
{ name: 'Node 1', depth: 2, indicies: [] },
]);
const ndvStoreMock: MockInstance = vi.spyOn(ndvStore, 'useNDVStore');
ndvStoreMock.mockReturnValue({ activeNode: nodes[2] });
expect(autocompletableNodeNames()).toEqual(['Node 2', 'Node 1']);
expect(autocompletableNodeNames(mockWorkflowDocumentStore)).toEqual(['Node 2', 'Node 1']);
});
it('should work for AI tool nodes', () => {
@ -133,15 +134,15 @@ describe('completion utils', () => {
];
mockedStore(useWorkflowsStore);
mockWorkflowDocumentStore.getChildNodes.mockReturnValue(['Agent']);
mockWorkflowDocumentStore.getParentNodesByDepth.mockReturnValue([
{ name: 'Normal Node', depth: 1 },
vi.mocked(mockWorkflowDocumentStore.getChildNodes).mockReturnValue(['Agent']);
vi.mocked(mockWorkflowDocumentStore.getParentNodesByDepth).mockReturnValue([
{ name: 'Normal Node', depth: 1, indicies: [] },
]);
const ndvStoreMock: MockInstance = vi.spyOn(ndvStore, 'useNDVStore');
ndvStoreMock.mockReturnValue({ activeNode: nodes[2] });
expect(autocompletableNodeNames()).toEqual(['Normal Node']);
expect(autocompletableNodeNames(mockWorkflowDocumentStore)).toEqual(['Normal Node']);
});
});

View File

@ -1,6 +1,5 @@
import { HTTP_REQUEST_NODE_TYPE, SPLIT_IN_BATCHES_NODE_TYPE } from '@/app/constants';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { resolveParameter } from '@/app/composables/useWorkflowHelpers';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { useUIStore } from '@/app/stores/ui.store';
@ -18,8 +17,8 @@ import type { DocMetadata } from 'n8n-workflow';
import { escapeMappingString } from '@/app/utils/mappingUtils';
import type { TargetNodeParameterContext } from '@/Interface';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
type WorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
/**
@ -167,21 +166,33 @@ export const isValidJavascriptIdentifier = (str: string) => {
// resolution-based utils
// ----------------------------------
export async function receivesNoBinaryData(contextNodeName?: string) {
export async function receivesNoBinaryData(
workflowDocumentId: WorkflowDocumentId,
contextNodeName?: string,
) {
try {
return (
(await resolveAutocompleteExpression('={{ $binary }}', contextNodeName))?.data === undefined
(await resolveAutocompleteExpression('={{ $binary }}', workflowDocumentId, contextNodeName))
?.data === undefined
);
} catch {
return true;
}
}
export async function hasNoParams(toResolve: string, contextNodeName?: string) {
export async function hasNoParams(
toResolve: string,
workflowDocumentId: WorkflowDocumentId,
contextNodeName?: string,
) {
let params;
try {
params = await resolveAutocompleteExpression(`={{ ${toResolve}.params }}`, contextNodeName);
params = await resolveAutocompleteExpression(
`={{ ${toResolve}.params }}`,
workflowDocumentId,
contextNodeName,
);
} catch {
return true;
}
@ -193,7 +204,11 @@ export async function hasNoParams(toResolve: string, contextNodeName?: string) {
return paramKeys.length === 1 && isPseudoParam(paramKeys[0]);
}
export async function resolveAutocompleteExpression(expression: string, contextNodeName?: string) {
export async function resolveAutocompleteExpression(
expression: string,
workflowDocumentId: WorkflowDocumentId,
contextNodeName?: string,
) {
const ndvStore = useNDVStore();
const inputData =
contextNodeName === undefined && ndvStore.isInputParentOfActiveNode
@ -204,7 +219,7 @@ export async function resolveAutocompleteExpression(expression: string, contextN
inputBranchIndex: ndvStore.ndvInputBranchIndex,
}
: {};
return await resolveParameter(expression, {
return await resolveParameter(expression, workflowDocumentId, {
...inputData,
contextNodeName,
});
@ -231,7 +246,10 @@ export const isInHttpNodePagination = (targetNodeParameterContext?: TargetNodePa
return nodeType === HTTP_REQUEST_NODE_TYPE && path.startsWith('parameters.options.pagination');
};
export const hasActiveNode = (targetNodeParameterContext?: TargetNodeParameterContext) => {
export const hasActiveNode = (
workflowDocumentId: WorkflowDocumentId,
targetNodeParameterContext?: TargetNodeParameterContext,
) => {
if (useNDVStore().activeNode?.name !== undefined) {
return true;
}
@ -240,28 +258,20 @@ export const hasActiveNode = (targetNodeParameterContext?: TargetNodeParameterCo
return false;
}
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
const workflowDocumentStore = useWorkflowDocumentStore(workflowDocumentId);
return workflowDocumentStore.getNodeByName(targetNodeParameterContext.nodeName) !== null;
};
export const isSplitInBatchesAbsent = () => {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
export const isSplitInBatchesAbsent = (workflowDocumentId: WorkflowDocumentId) => {
const workflowDocumentStore = useWorkflowDocumentStore(workflowDocumentId);
return !workflowDocumentStore.allNodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE);
};
export function autocompletableNodeNames(targetNodeParameterContext?: TargetNodeParameterContext) {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
export function autocompletableNodeNames(
workflowDocumentId: WorkflowDocumentId,
targetNodeParameterContext?: TargetNodeParameterContext,
) {
const workflowDocumentStore = useWorkflowDocumentStore(workflowDocumentId);
const activeNode =
targetNodeParameterContext === undefined
? useNDVStore().activeNode
@ -275,17 +285,14 @@ export function autocompletableNodeNames(targetNodeParameterContext?: TargetNode
// This is a tool node, look for the nearest node with main connections
if (nonMainChildren.length > 0) {
return nonMainChildren.map(getPreviousNodes).flat();
return nonMainChildren.map((child) => getPreviousNodes(workflowDocumentId, child)).flat();
}
return getPreviousNodes(activeNodeName);
return getPreviousNodes(workflowDocumentId, activeNodeName);
}
export function getPreviousNodes(nodeName: string) {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
export function getPreviousNodes(workflowDocumentId: WorkflowDocumentId, nodeName: string) {
const workflowDocumentStore = useWorkflowDocumentStore(workflowDocumentId);
return workflowDocumentStore
.getParentNodesByDepth(nodeName)
.map((node) => node.name)

View File

@ -7,6 +7,7 @@ import { hoverTooltipSource, infoBoxTooltips } from './InfoBoxTooltip';
import * as utils from '@/features/shared/editors/plugins/codemirror/completions/utils';
import * as workflowHelpers from '@/app/composables/useWorkflowHelpers';
import { completionStatus } from '@codemirror/autocomplete';
import { WORKFLOW_DOCUMENT_FACET } from '@/features/shared/editors/plugins/codemirror/completions/constants';
vi.mock('@codemirror/autocomplete', async (importOriginal) => {
const actual = await importOriginal<{}>();
@ -162,7 +163,7 @@ async function hoverTooltip(docWithCursor: string) {
const state = EditorState.create({
doc,
extensions: [n8nLang(), infoBoxTooltips()],
extensions: [n8nLang(), infoBoxTooltips(), WORKFLOW_DOCUMENT_FACET.of('test@latest')],
});
const view = new EditorView({ state, parent: document.createElement('div') });

View File

@ -9,7 +9,7 @@ import {
import { autocompletableNodeNames, longestCommonPrefix } from '../../completions/utils';
import { typescriptWorkerFacet } from './facet';
import { blockCommentSnippet, snippets } from './snippets';
import { TARGET_NODE_PARAMETER_FACET } from '../../completions/constants';
import { TARGET_NODE_PARAMETER_FACET, WORKFLOW_DOCUMENT_FACET } from '../../completions/constants';
import type { AliasCompletion, Alias } from 'n8n-workflow';
import { sortCompletionsByInput } from '../../completions/datatype.completions';
@ -30,6 +30,7 @@ export const matchText = (context: CompletionContext) => {
export const typescriptCompletionSource: CompletionSource = async (context) => {
const { worker } = context.state.facet(typescriptWorkerFacet);
const targetNodeParameter = context.state.facet(TARGET_NODE_PARAMETER_FACET);
const workflowDocumentId = context.state.facet(WORKFLOW_DOCUMENT_FACET);
const word = matchText(context);
const blockComment = context.matchBefore(/\/\*?\*?/);
@ -96,7 +97,7 @@ export const typescriptCompletionSource: CompletionSource = async (context) => {
if (opt.label === '$()') {
return [
opt,
...autocompletableNodeNames(targetNodeParameter).map((name) => ({
...autocompletableNodeNames(workflowDocumentId, targetNodeParameter).map((name) => ({
...opt,
label: `$('${escapeMappingString(name)}')`,
})),

View File

@ -23,7 +23,7 @@ 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';
import { TARGET_NODE_PARAMETER_FACET, WORKFLOW_DOCUMENT_FACET } from '../../completions/constants';
export function useTypescript(
view: MaybeRefOrGetter<EditorView | undefined>,
@ -49,7 +49,10 @@ export function useTypescript(
{
id: toValue(id),
content: Comlink.proxy((toValue(view)?.state.doc ?? Text.empty).toJSON()),
allNodeNames: autocompletableNodeNames(toValue(targetNodeParameterContext)),
allNodeNames: autocompletableNodeNames(
workflowDocumentStore.value.documentId,
toValue(targetNodeParameterContext),
),
variables: useEnvironmentsStore().scopedVariables.map((v) => v.key),
inputNodeNames: activeNodeName
? (workflowDocumentStore?.value?.getParentNodes(
@ -99,6 +102,7 @@ export function useTypescript(
return [
typescriptWorkerFacet.of({ worker: worker.value }),
TARGET_NODE_PARAMETER_FACET.of(toValue(targetNodeParameterContext)),
WORKFLOW_DOCUMENT_FACET.of(workflowDocumentStore.value.documentId),
new LanguageSupport(javascriptLanguage, [
javascriptLanguage.data.of({ autocomplete: typescriptCompletionSource }),
]),

View File

@ -24,21 +24,19 @@ import { collectParametersByTab, createCommonNodeSettings } from '@/features/ndv
import type { INodeUpdatePropertiesInformation, ITab, IUpdateInformation } from '@/Interface';
import { N8nTabs, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import {
Workflow,
NodeHelpers,
deepCopy,
type INode,
type INodeParameters,
type INodeTypes,
type INodeType,
type IVersionedNodeType,
type IDataObject,
} from 'n8n-workflow';
import { Workflow, NodeHelpers, deepCopy, type INode, type INodeParameters } from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, watch } from 'vue';
import { ChatHubToolContextKey, ExpressionLocalResolveContextSymbol } from '@/app/constants';
import {
ChatHubToolContextKey,
ExpressionLocalResolveContextSymbol,
WorkflowDocumentStoreKey,
} from '@/app/constants';
import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
const props = defineProps<{
initialNode: INode;
@ -139,54 +137,24 @@ const hasCredentialIssues = computed(() => {
return Object.keys(credentialIssues?.credentials ?? {}).length > 0;
});
const workflowDocumentStore = computed(() => {
const store = useWorkflowDocumentStore(createWorkflowDocumentId('node-tool-workflow'));
if (node.value) {
store.setNodes([node.value]);
}
return store;
});
const expressionResolveCtx = computed<ExpressionLocalResolveContext | undefined>(() => {
if (!node.value) return undefined;
const nodeTypes: INodeTypes = {
getByName(nodeType: string): INodeType | IVersionedNodeType {
const description = nodeTypesStore.getNodeType(nodeType);
if (description === null) {
throw new Error(`Node type "${nodeType}" not found`);
}
return {
description,
} as INodeType;
},
getByNameAndVersion(nodeType: string, version?: number): INodeType {
const description = nodeTypesStore.getNodeType(nodeType, version);
if (description === null) {
throw new Error(`Node type "${nodeType}" (v${version}) not found`);
}
return {
description,
} as INodeType;
},
getKnownTypes(): IDataObject {
return {};
},
};
// Minimal workflow containing only this node for parameter resolution
const workflow = new Workflow({
id: 'node-tool-workflow',
name: 'Tool Configuration',
nodes: [node.value],
connections: {},
active: false,
nodeTypes,
settings: {},
});
return {
localResolve: true,
envVars: environmentsStore.variablesAsObject,
workflow,
execution: null,
nodeName: node.value.name,
additionalKeys: {},
connections: {},
inputNode: undefined,
};
});
@ -197,6 +165,7 @@ const isValid = computed(() => {
// Provide expression resolve context for dynamic parameter loading
provide(ExpressionLocalResolveContextSymbol, expressionResolveCtx);
provide(WorkflowDocumentStoreKey, workflowDocumentStore);
provide(ChatHubToolContextKey, true);
function makeUniqueName(baseName: string, existingNames: string[]): string {

View File

@ -1,9 +1,8 @@
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
import { createTestNode } from '@/__tests__/mocks';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { render, waitFor } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { NodeConnectionTypes } from 'n8n-workflow';
import { computed, nextTick } from 'vue';
import { WorkflowIdKey } from '@/app/constants/injectionKeys';
import ExperimentalEmbeddedNdvMapper from './ExperimentalEmbeddedNdvMapper.vue';
@ -11,14 +10,6 @@ import { useExperimentalNdvStore } from '../experimentalNdv.store';
describe('ExperimentalEmbeddedNdvMapper', () => {
const node = createTestNode({ name: 'n1' });
const workflow = createTestWorkflowObject({
nodes: [node],
connections: {
n0: {
[NodeConnectionTypes.Main]: [[{ index: 0, node: 'n0', type: NodeConnectionTypes.Main }]],
},
},
});
it('should open the popover on hover if visibleOnHover is true', async () => {
const reference = document.createElement('div');
@ -28,7 +19,6 @@ describe('ExperimentalEmbeddedNdvMapper', () => {
plugins: [createTestingPinia({ stubActions: false })],
},
props: {
workflow,
node,
inputNodeName: 'n0',
reference,
@ -49,7 +39,6 @@ describe('ExperimentalEmbeddedNdvMapper', () => {
plugins: [createTestingPinia({ stubActions: false })],
},
props: {
workflow,
node,
inputNodeName: 'n0',
reference,
@ -72,7 +61,6 @@ describe('ExperimentalEmbeddedNdvMapper', () => {
plugins: [pinia],
},
props: {
workflow,
node,
inputNodeName: 'n0',
reference,
@ -95,7 +83,6 @@ describe('ExperimentalEmbeddedNdvMapper', () => {
plugins: [createTestingPinia({ stubActions: false })],
},
props: {
workflow,
node,
inputNodeName: 'n0',
reference,

View File

@ -14,7 +14,6 @@ import { useExperimentalNdvStore } from '../experimentalNdv.store';
import { isEventTargetContainedBy } from '@/app/utils/htmlUtils';
import { N8nPopover } from '@n8n/design-system';
import type { WorkflowObjectAccessors } from '@/app/types';
type MapperState = { isOpen: true; closeOnMouseLeave: boolean } | { isOpen: false };
const hoverOptions: UseElementHoverOptions = {
@ -27,7 +26,6 @@ const {
reference,
visibleOnHover = false,
} = defineProps<{
workflow: WorkflowObjectAccessors;
node: INodeUi;
inputNodeName: string;
visibleOnHover?: boolean;
@ -115,7 +113,6 @@ onClickOutside(contentElRef, handleReferenceFocusOut);
ref="content"
:tabindex="-1"
:class="$style.inputPanel"
:workflow-object="workflow"
:run-index="0"
compact
push-ref=""

View File

@ -1,12 +1,10 @@
import type { INodeUi } from '@/Interface';
import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
import { computed, type ComputedRef } from 'vue';
export function useExpressionResolveCtx(node: ComputedRef<INodeUi | null | undefined>) {
const environmentsStore = useEnvironmentsStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
@ -47,13 +45,9 @@ export function useExpressionResolveCtx(node: ComputedRef<INodeUi | null | undef
return {
localResolve: true,
envVars: environmentsStore.variablesAsObject,
workflow: workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot(),
execution,
nodeName,
additionalKeys: {},
inputNode: findInputNode(),
connections: workflowDocumentStore.value.connectionsBySourceNode,
};
});
}