feat(editor): Allow expressions to autocomplete project variables (#20269)

This commit is contained in:
Guillaume Jacquart 2025-10-08 10:53:52 +02:00 committed by GitHub
parent 3c49ccc00c
commit 2a7b34197a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 172 additions and 37 deletions

View File

@ -359,7 +359,9 @@
"codeNodeEditor.completer.$nodeVersion": "The version of the current node (as displayed at the bottom of the nodes's settings pane)",
"codeNodeEditor.completer.$today": "A DateTime representing midnight at the start of the current day. \n\nUses the instance's time zone (unless overridden in the workflow's settings).",
"codeNodeEditor.completer.$vars": "The <a target=\"_blank\" href=\"https://docs.n8n.io/code/variables/\">variables</a> available to the workflow",
"codeNodeEditor.completer.$vars.varName": "Variable set on this n8n instance. All variables evaluate to strings.",
"codeNodeEditor.completer.$vars.varName.global": "Global variable defined for this n8n instance. All variables evaluate to strings.",
"codeNodeEditor.completer.$vars.varName.global.overridden": "Global variable overridden by project {projectName} variable. All variables evaluate to strings. ",
"codeNodeEditor.completer.$vars.varName.project": "Project variable defined in the {projectName} project. All variables evaluate to strings.",
"codeNodeEditor.completer.$secrets": "The secrets from an <a target=\"_blank\" href=\"https://docs.n8n.io/external-secrets/\">external secrets vault</a>, if configured. Secret values are never displayed to the user. Only available in credential fields.",
"codeNodeEditor.completer.$secrets.provider": "External secrets providers connected to this n8n instance.",
"codeNodeEditor.completer.$secrets.provider.varName": "External secrets connected to this n8n instance. All secrets evaluate to strings.",
@ -544,6 +546,8 @@
"codeNodeEditor.completer.section.cast": "Cast",
"codeNodeEditor.completer.section.compare": "Compare",
"codeNodeEditor.completer.section.validation": "Validate",
"codeNodeEditor.completer.section.variable.project": "Project Variables",
"codeNodeEditor.completer.section.variable.global": "Global Variables",
"codeNodeEditor.linter.allItems.firstOrLastCalledWithArg": "expects no argument.",
"codeNodeEditor.linter.allItems.emptyReturn": "Code doesn't return items properly. Please return an array of objects, one for each item you would like to output.",
"codeNodeEditor.linter.allItems.itemCall": "`item` is a property to access, not a method to call. Did you mean `.item` without brackets?",
@ -749,7 +753,7 @@
"dataMapping.schemaView.previewNode": "Preview",
"dataMapping.schemaView.variablesContextTitle": "Variables and context",
"dataMapping.schemaView.execution.resumeUrl": "The URL for resuming a 'Wait' node",
"dataMapping.schemaView.variablesUpgrade": "Set global variables and use them across workflows with the Pro or Enterprise plan. <a href=\"https://docs.n8n.io/environments/variables/\" target=\"_blank\">Details</a>",
"dataMapping.schemaView.variablesUpgrade": "Set global variables and use them across workflows with the Pro or Enterprise plan. <a href=\"https://docs.n8n.io/code/variables/\" target=\"_blank\">Details</a>",
"dataMapping.schemaView.variablesEmpty": "Create variables that can be used across workflows <a href=\"/variables\" target=\"_blank\">here</a>",
"displayWithChange.cancelEdit": "Cancel Edit",
"displayWithChange.clickToChange": "Click to Change",
@ -2915,7 +2919,7 @@
"contextual.workflows.sharing.unavailable.button.cloud": "Upgrade now",
"contextual.variables.unavailable.title": "Available on the Enterprise plan",
"contextual.variables.unavailable.title.cloud": "Available on Pro plan",
"contextual.variables.unavailable.description": "Variables can be used to store and access data across workflows. Reference them in n8n using the prefix <code>$vars</code> (e.g. <code>$vars.myVariable</code>). Variables are immutable and cannot be modified within your workflows.<br/><a href=\"https://docs.n8n.io/environments/variables/\" target=\"_blank\">Learn more in the docs.</a>",
"contextual.variables.unavailable.description": "Variables can be used to store and access data across workflows. Reference them in n8n using the prefix <code>$vars</code> (e.g. <code>$vars.myVariable</code>). Variables are immutable and cannot be modified within your workflows.<br/><a href=\"https://docs.n8n.io/code/variables/\" target=\"_blank\">Learn more in the docs.</a>",
"contextual.variables.unavailable.button": "View plans",
"contextual.variables.unavailable.button.cloud": "Upgrade now",
"contextual.users.settings.unavailable.title": "Upgrade to add users",

View File

@ -233,7 +233,7 @@ const contextItems = computed(() => {
return renderItem;
}
if (isVarsOpen && environmentsStore.variables.length === 0) {
if (isVarsOpen && environmentsStore.scopedVariables.length === 0) {
const variablesEmptyNotice: RenderNotice = {
type: 'notice',
id: 'notice-variablesEmpty',

View File

@ -439,6 +439,17 @@ export const STRING_SECTIONS: Record<string, CompletionSection> = {
}),
};
export const VARIABLE_SECTIONS: Record<string, CompletionSection> = {
project: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.variable.project'),
rank: 1,
}),
global: withSectionHeader({
name: i18n.baseText('codeNodeEditor.completer.section.variable.global'),
rank: 2,
}),
};
export const TARGET_NODE_PARAMETER_FACET = Facet.define<
TargetNodeParameterContext | undefined,
TargetNodeParameterContext | undefined

View File

@ -29,6 +29,7 @@ import {
STRING_RECOMMENDED_OPTIONS,
STRING_SECTIONS,
TARGET_NODE_PARAMETER_FACET,
VARIABLE_SECTIONS,
} from './constants';
import { createInfoBoxRenderer } from './infoBoxRenderer';
import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs';
@ -316,11 +317,13 @@ const createCompletionOption = ({
doc,
isFunction = false,
transformLabel = (label) => label,
type,
}: {
name: string;
doc?: DocMetadata;
isFunction?: boolean;
transformLabel?: (label: string) => string;
type?: 'strikethrough';
}): Completion => {
const label = isFunction ? name + '()' : name;
const option: Completion = {
@ -331,6 +334,7 @@ const createCompletionOption = ({
defaultArgs: getDefaultArgs(doc),
transformLabel,
}),
type,
};
option.info = createInfoBoxRenderer(doc, isFunction);
@ -704,19 +708,43 @@ function ensureKeyCanBeResolved(obj: IDataObject, key: string) {
export const variablesOptions = () => {
const environmentsStore = useEnvironmentsStore();
const variables = environmentsStore.variables;
const variables = environmentsStore.scopedVariables;
return variables.map((variable) =>
createCompletionOption({
name: variable.key,
doc: {
const getDescription = (isGlobal: boolean, isOverridden: boolean, projectName?: string) => {
if (isGlobal && isOverridden) {
return i18n.baseText('codeNodeEditor.completer.$vars.varName.global.overridden', {
interpolate: { projectName: projectName ?? '' },
});
}
if (isGlobal) {
return i18n.baseText('codeNodeEditor.completer.$vars.varName.global');
}
return i18n.baseText('codeNodeEditor.completer.$vars.varName.project', {
interpolate: { projectName: projectName ?? '' },
});
};
return applySections({
options: variables.map((variable) => {
const isOverridden =
!variable.project && !!variables.find((v) => v.key === variable.key && v.project);
return createCompletionOption({
name: variable.key,
returnType: 'string',
description: i18n.baseText('codeNodeEditor.completer.$vars.varName'),
docURL: 'https://docs.n8n.io/environments/variables/',
},
doc: {
section: variable.project ? 'project' : 'global',
name: variable.key,
returnType: 'string',
description: getDescription(!variable.project, isOverridden, variable.project?.name),
docURL: 'https://docs.n8n.io/code/variables/',
},
type: isOverridden ? 'strikethrough' : undefined,
});
}),
);
sections: VARIABLE_SECTIONS,
});
};
export const responseOptions = () => {

View File

@ -4,6 +4,7 @@ import { parseMixed, type SyntaxNodeRef } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { n8nCompletionSources } from './completions/addCompletions';
import type { Completion } from '@codemirror/autocomplete';
import { autocompletion } from '@codemirror/autocomplete';
import { expressionCloseBracketsConfig } from './expressionCloseBrackets';
@ -29,4 +30,9 @@ export function n8nLang() {
}
export const n8nAutocompletion = () =>
autocompletion({ icons: false, aboveCursor: true, closeOnBlur: false });
autocompletion({
icons: false,
aboveCursor: true,
closeOnBlur: false,
optionClass: (completion: Completion) => completion.type ?? '',
});

View File

@ -48,7 +48,7 @@ export function useTypescript(
id: toValue(id),
content: Comlink.proxy((toValue(view)?.state.doc ?? Text.empty).toJSON()),
allNodeNames: autocompletableNodeNames(toValue(targetNodeParameterContext)),
variables: useEnvironmentsStore().variables.map((v) => v.key),
variables: useEnvironmentsStore().scopedVariables.map((v) => v.key),
inputNodeNames: activeNodeName
? workflowsStore.workflowObject.getParentNodes(
activeNodeName,

View File

@ -13,10 +13,10 @@ beforeEach(() => {
describe('variablesCompletions', () => {
test('should return completions for $vars prefix', () => {
environmentsStore.variables = [
vi.spyOn(environmentsStore, 'scopedVariables', 'get').mockReturnValue([
{ key: 'VAR1', value: 'Value1', id: '1' },
{ key: 'VAR2', value: 'Value2', id: '2' },
];
]);
const state = EditorState.create({ doc: '$vars.', selection: { anchor: 6 } });
const context = new CompletionContext(state, 6, true);
@ -39,7 +39,13 @@ describe('variablesCompletions', () => {
});
test('should escape special characters in matcher', () => {
environmentsStore.variables = [{ key: 'VAR1', value: 'Value1', id: '1' }];
vi.spyOn(environmentsStore, 'scopedVariables', 'get').mockReturnValue([
{
key: 'VAR1',
value: 'Value1',
id: '1',
},
]);
const state = EditorState.create({ doc: '$vars.', selection: { anchor: 6 } });
const context = new CompletionContext(state, 6, true);
@ -49,7 +55,13 @@ describe('variablesCompletions', () => {
});
test('should return completions for custom matcher', () => {
environmentsStore.variables = [{ key: 'VAR1', value: 'Value1', id: '1' }];
vi.spyOn(environmentsStore, 'scopedVariables', 'get').mockReturnValue([
{
key: 'VAR1',
value: 'Value1',
id: '1',
},
]);
const state = EditorState.create({ doc: '$custom.', selection: { anchor: 8 } });
const context = new CompletionContext(state, 8, true);

View File

@ -21,7 +21,7 @@ export function useVariablesCompletions() {
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const options: Completion[] = environmentsStore.variables.map((variable) => ({
const options: Completion[] = environmentsStore.scopedVariables.map((variable) => ({
label: `${matcher}.${variable.key}`,
info: variable.value,
}));

View File

@ -4,16 +4,34 @@ import type { EnvironmentVariable } from './environments.types';
import * as environmentsApi from './environments.api';
import { useRootStore } from '@n8n/stores/useRootStore';
import { ExpressionError } from 'n8n-workflow';
import { useProjectsStore } from '@/stores/projects.store';
export const useEnvironmentsStore = defineStore('environments', () => {
const rootStore = useRootStore();
const projectStore = useProjectsStore();
const variables = ref<EnvironmentVariable[]>([]);
const allVariables = ref<EnvironmentVariable[]>([]);
const projectId = computed(() => projectStore.currentProject?.id);
// Global variables plus project-specific ones. Includes all projects if none is selected
const variables = computed(() =>
allVariables.value.filter(
(v) => !v.project || !projectId.value || v.project.id === projectId.value,
),
);
// Scoped variables: global variables plus variables for the current project only.
// If no project is selected, only global variables are included
const scopedVariables = computed(() =>
allVariables.value.filter(
(v) => !v.project || (!projectId.value && !v.project) || v.project.id === projectId.value,
),
);
async function fetchAllVariables() {
const data = await environmentsApi.getVariables(rootStore.restApiContext);
variables.value = data;
allVariables.value = data;
return data;
}
@ -21,7 +39,7 @@ export const useEnvironmentsStore = defineStore('environments', () => {
async function createVariable(variable: Omit<EnvironmentVariable, 'id'>) {
const data = await environmentsApi.createVariable(rootStore.restApiContext, variable);
variables.value.unshift(data);
allVariables.value.unshift(data);
return data;
}
@ -29,7 +47,7 @@ export const useEnvironmentsStore = defineStore('environments', () => {
async function updateVariable(variable: EnvironmentVariable) {
const data = await environmentsApi.updateVariable(rootStore.restApiContext, variable);
variables.value = variables.value.map((v) => (v.id === data.id ? data : v));
allVariables.value = allVariables.value.map((v) => (v.id === data.id ? data : v));
return data;
}
@ -39,13 +57,13 @@ export const useEnvironmentsStore = defineStore('environments', () => {
id: variable.id,
});
variables.value = variables.value.filter((v) => v.id !== variable.id);
allVariables.value = allVariables.value.filter((v) => v.id !== variable.id);
return data;
}
const variablesAsObject = computed(() => {
const asObject = variables.value.reduce<Record<string, string | boolean | number>>(
const asObject = scopedVariables.value.reduce<Record<string, string | boolean | number>>(
(acc, variable) => {
acc[variable.key] = variable.value;
return acc;
@ -62,6 +80,7 @@ export const useEnvironmentsStore = defineStore('environments', () => {
return {
variables,
scopedVariables,
variablesAsObject,
fetchAllVariables,
createVariable,

View File

@ -3,14 +3,42 @@ import { setActivePinia, createPinia } from 'pinia';
import { setupServer } from '@/__tests__/server';
import { useEnvironmentsStore } from './environments.store';
import type { EnvironmentVariable } from './environments.types';
import type { Project } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
describe('environments.store', () => {
let server: ReturnType<typeof setupServer>;
const seedRecordsCount = 3;
beforeAll(() => {
server = setupServer();
server.createList('variable', seedRecordsCount);
server.create('variable', {
id: '1',
key: 'var1',
value: 'value1',
});
server.create('variable', {
id: '2',
key: 'var2',
value: 'value2',
});
// Create one variable linked to a project
server.create('variable', {
id: '3',
key: 'var3',
value: 'value3',
project: { id: '1', name: 'Project 1' },
});
// Create one variable linked to a another project
server.create('variable', {
id: '4',
key: 'var4',
value: 'value4',
project: { id: '2', name: 'Project 2' },
});
});
beforeEach(() => {
@ -23,11 +51,32 @@ describe('environments.store', () => {
describe('variables', () => {
describe('fetchAllVariables()', () => {
it('should fetch all credentials', async () => {
it('should fetch all variables', async () => {
const environmentsStore = useEnvironmentsStore();
await environmentsStore.fetchAllVariables();
expect(environmentsStore.variables).toHaveLength(seedRecordsCount);
expect(environmentsStore.variables).toHaveLength(4);
expect(environmentsStore.scopedVariables).toHaveLength(2);
});
it('should list all variables excluding different project variable', async () => {
const environmentsStore = useEnvironmentsStore();
const projectStore = useProjectsStore();
projectStore.setCurrentProject({ id: '3', name: 'Project 3' } as Project);
await environmentsStore.fetchAllVariables();
expect(environmentsStore.variables).toHaveLength(2);
expect(environmentsStore.scopedVariables).toHaveLength(2);
});
it('should list all variables with a current project set matching variable', async () => {
const environmentsStore = useEnvironmentsStore();
const projectStore = useProjectsStore();
projectStore.setCurrentProject({ id: '1', name: 'Project 1' } as Project);
await environmentsStore.fetchAllVariables();
expect(environmentsStore.variables).toHaveLength(3);
expect(environmentsStore.scopedVariables).toHaveLength(3);
});
});
@ -86,13 +135,14 @@ describe('environments.store', () => {
it('should return variables as a key-value object', async () => {
const environmentsStore = useEnvironmentsStore();
await environmentsStore.fetchAllVariables();
const projectStore = useProjectsStore();
projectStore.setCurrentProject({ id: '1', name: 'Project 1' } as Project);
expect(environmentsStore.variablesAsObject).toEqual(
environmentsStore.variables.reduce<Record<string, string>>((acc, variable) => {
acc[variable.key] = variable.value;
return acc;
}, {}),
);
expect(environmentsStore.variablesAsObject).toEqual({
ENV_VAR: 'SECRET',
var2: 'value2',
var3: 'value3',
});
});
});
});

View File

@ -2,4 +2,5 @@ export interface EnvironmentVariable {
id: string;
key: string;
value: string;
project?: { id: string; name: string } | null;
}

View File

@ -60,6 +60,10 @@
gap: var(--spacing-2xs);
scroll-padding: 40px;
scroll-margin: 40px;
&.strikethrough {
text-decoration: line-through;
}
}
li .cm-completionLabel {