mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 01:07:04 +02:00
feat(editor): Allow expressions to autocomplete project variables (#20269)
This commit is contained in:
parent
3c49ccc00c
commit
2a7b34197a
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ export interface EnvironmentVariable {
|
|||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
project?: { id: string; name: string } | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@
|
|||
gap: var(--spacing-2xs);
|
||||
scroll-padding: 40px;
|
||||
scroll-margin: 40px;
|
||||
|
||||
&.strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
li .cm-completionLabel {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user