From 2a7b34197a63133cdeef8a9d6794b1e11bc08bc2 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Wed, 8 Oct 2025 10:53:52 +0200 Subject: [PATCH] feat(editor): Allow expressions to autocomplete project variables (#20269) --- .../frontend/@n8n/i18n/src/locales/en.json | 10 ++- .../src/components/VirtualSchema.vue | 2 +- .../codemirror/completions/constants.ts | 11 +++ .../completions/datatype.completions.ts | 48 ++++++++++--- .../editors/plugins/codemirror/n8nLang.ts | 8 ++- .../typescript/client/useTypescript.ts | 2 +- .../completions/variables.completions.test.ts | 20 ++++-- .../completions/variables.completions.ts | 2 +- .../environments.ee/environments.store.ts | 31 ++++++-- .../environments.ee/environments.test.ts | 70 ++++++++++++++++--- .../environments.ee/environments.types.ts | 1 + .../src/styles/plugins/_codemirror.scss | 4 ++ 12 files changed, 172 insertions(+), 37 deletions(-) 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 {