diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index c79f9132e25..d0fe2e02296 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -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 variables 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 external secrets vault, 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. Details",
+ "dataMapping.schemaView.variablesUpgrade": "Set global variables and use them across workflows with the Pro or Enterprise plan. Details",
"dataMapping.schemaView.variablesEmpty": "Create variables that can be used across workflows here",
"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 $vars (e.g. $vars.myVariable). Variables are immutable and cannot be modified within your workflows.
Learn more in the docs.",
+ "contextual.variables.unavailable.description": "Variables can be used to store and access data across workflows. Reference them in n8n using the prefix $vars (e.g. $vars.myVariable). Variables are immutable and cannot be modified within your workflows.
Learn more in the docs.",
"contextual.variables.unavailable.button": "View plans",
"contextual.variables.unavailable.button.cloud": "Upgrade now",
"contextual.users.settings.unavailable.title": "Upgrade to add users",
diff --git a/packages/frontend/editor-ui/src/components/VirtualSchema.vue b/packages/frontend/editor-ui/src/components/VirtualSchema.vue
index a7def31aa54..592c92cb2c1 100644
--- a/packages/frontend/editor-ui/src/components/VirtualSchema.vue
+++ b/packages/frontend/editor-ui/src/components/VirtualSchema.vue
@@ -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',
diff --git a/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/completions/constants.ts b/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/completions/constants.ts
index a47c19fa306..63dfdac2a91 100644
--- a/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/completions/constants.ts
+++ b/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/completions/constants.ts
@@ -439,6 +439,17 @@ export const STRING_SECTIONS: Record = {
}),
};
+export const VARIABLE_SECTIONS: Record = {
+ 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
diff --git a/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/completions/datatype.completions.ts b/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/completions/datatype.completions.ts
index d9e289c0056..f7bc7124e60 100644
--- a/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/completions/datatype.completions.ts
+++ b/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/completions/datatype.completions.ts
@@ -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 = () => {
diff --git a/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/n8nLang.ts b/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/n8nLang.ts
index 37ea74fb033..4757621ae69 100644
--- a/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/n8nLang.ts
+++ b/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/n8nLang.ts
@@ -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 ?? '',
+ });
diff --git a/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/typescript/client/useTypescript.ts b/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/typescript/client/useTypescript.ts
index 7c89512246e..1f7f5bebc07 100644
--- a/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/typescript/client/useTypescript.ts
+++ b/packages/frontend/editor-ui/src/features/editors/plugins/codemirror/typescript/client/useTypescript.ts
@@ -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,
diff --git a/packages/frontend/editor-ui/src/features/environments.ee/completions/variables.completions.test.ts b/packages/frontend/editor-ui/src/features/environments.ee/completions/variables.completions.test.ts
index cf8831d4bed..4a87300afdf 100644
--- a/packages/frontend/editor-ui/src/features/environments.ee/completions/variables.completions.test.ts
+++ b/packages/frontend/editor-ui/src/features/environments.ee/completions/variables.completions.test.ts
@@ -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);
diff --git a/packages/frontend/editor-ui/src/features/environments.ee/completions/variables.completions.ts b/packages/frontend/editor-ui/src/features/environments.ee/completions/variables.completions.ts
index 73facbcbbe5..23d92881ac7 100644
--- a/packages/frontend/editor-ui/src/features/environments.ee/completions/variables.completions.ts
+++ b/packages/frontend/editor-ui/src/features/environments.ee/completions/variables.completions.ts
@@ -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,
}));
diff --git a/packages/frontend/editor-ui/src/features/environments.ee/environments.store.ts b/packages/frontend/editor-ui/src/features/environments.ee/environments.store.ts
index b19b3aca6f9..acac66352a5 100644
--- a/packages/frontend/editor-ui/src/features/environments.ee/environments.store.ts
+++ b/packages/frontend/editor-ui/src/features/environments.ee/environments.store.ts
@@ -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([]);
+ const allVariables = ref([]);
+ 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) {
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>(
+ const asObject = scopedVariables.value.reduce>(
(acc, variable) => {
acc[variable.key] = variable.value;
return acc;
@@ -62,6 +80,7 @@ export const useEnvironmentsStore = defineStore('environments', () => {
return {
variables,
+ scopedVariables,
variablesAsObject,
fetchAllVariables,
createVariable,
diff --git a/packages/frontend/editor-ui/src/features/environments.ee/environments.test.ts b/packages/frontend/editor-ui/src/features/environments.ee/environments.test.ts
index 5478a279049..40a7b26a7f3 100644
--- a/packages/frontend/editor-ui/src/features/environments.ee/environments.test.ts
+++ b/packages/frontend/editor-ui/src/features/environments.ee/environments.test.ts
@@ -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;
- 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>((acc, variable) => {
- acc[variable.key] = variable.value;
- return acc;
- }, {}),
- );
+ expect(environmentsStore.variablesAsObject).toEqual({
+ ENV_VAR: 'SECRET',
+ var2: 'value2',
+ var3: 'value3',
+ });
});
});
});
diff --git a/packages/frontend/editor-ui/src/features/environments.ee/environments.types.ts b/packages/frontend/editor-ui/src/features/environments.ee/environments.types.ts
index 878ed4d0bc2..197a6a7e643 100644
--- a/packages/frontend/editor-ui/src/features/environments.ee/environments.types.ts
+++ b/packages/frontend/editor-ui/src/features/environments.ee/environments.types.ts
@@ -2,4 +2,5 @@ export interface EnvironmentVariable {
id: string;
key: string;
value: string;
+ project?: { id: string; name: string } | null;
}
diff --git a/packages/frontend/editor-ui/src/styles/plugins/_codemirror.scss b/packages/frontend/editor-ui/src/styles/plugins/_codemirror.scss
index b9a03fa8d28..3ef558831d1 100644
--- a/packages/frontend/editor-ui/src/styles/plugins/_codemirror.scss
+++ b/packages/frontend/editor-ui/src/styles/plugins/_codemirror.scss
@@ -60,6 +60,10 @@
gap: var(--spacing-2xs);
scroll-padding: 40px;
scroll-margin: 40px;
+
+ &.strikethrough {
+ text-decoration: line-through;
+ }
}
li .cm-completionLabel {