feat(editor): Introduce proxy completions to expressions (#5075)

*  Introduce proxy completions to expressions

* 🧪 Add tests

*  Replace snippet with alphabetic char completions

*  Tighten `DateTime` check

* 🧹 Clean up `n8nLang`

* 🔥 Remove duplicate

* 👕 Remove non-null assertion

*  Confirm that `overlay` is needed

* 🔥 Remove comment

* 🔥 Remove more unneeded code

* 🔥 Remove unneded Pinia setup

*  Simplify syntax
This commit is contained in:
Iván Ovejero 2023-01-06 10:07:36 +01:00 committed by GitHub
parent 77031a2950
commit f4140d011f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1391 additions and 520 deletions

View file

@ -27,9 +27,9 @@
"test:dev": "vitest" "test:dev": "vitest"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.1.0", "@codemirror/autocomplete": "^6.4.0",
"@codemirror/commands": "^6.1.0", "@codemirror/commands": "^6.1.0",
"@codemirror/lang-javascript": "^6.0.2", "@codemirror/lang-javascript": "^6.1.2",
"@codemirror/language": "^6.2.1", "@codemirror/language": "^6.2.1",
"@codemirror/lint": "^6.0.0", "@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.1.4", "@codemirror/state": "^6.1.4",

View file

@ -8,7 +8,7 @@ import {
lineNumbers, lineNumbers,
} from '@codemirror/view'; } from '@codemirror/view';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'; import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { acceptCompletion, closeBrackets } from '@codemirror/autocomplete'; import { acceptCompletion } from '@codemirror/autocomplete';
import { import {
history, history,
indentWithTab, indentWithTab,
@ -16,11 +16,8 @@ import {
toggleComment, toggleComment,
} from '@codemirror/commands'; } from '@codemirror/commands';
import { lintGutter } from '@codemirror/lint'; import { lintGutter } from '@codemirror/lint';
import type { Extension } from '@codemirror/state';
import { customInputHandler } from './inputHandler'; import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler';
const [_, bracketState] = closeBrackets() as readonly Extension[];
export const baseExtensions = [ export const baseExtensions = [
lineNumbers(), lineNumbers(),
@ -29,7 +26,7 @@ export const baseExtensions = [
history(), history(),
foldGutter(), foldGutter(),
lintGutter(), lintGutter(),
[customInputHandler, bracketState], codeInputHandler(),
dropCursor(), dropCursor(),
indentOnInput(), indentOnInput(),
bracketMatching(), bracketMatching(),

View file

@ -54,7 +54,7 @@ export const completerExtension = mixins(
// luxon // luxon
this.todayCompletions, this.todayCompletions,
this.nowCompletions, this.nowCompletions,
this.dateTimeCompltions, this.dateTimeCompletions,
// item index // item index
this.inputCompletions, this.inputCompletions,
@ -174,7 +174,7 @@ export const completerExtension = mixins(
if (value === '$now') return this.nowCompletions(context, variable); if (value === '$now') return this.nowCompletions(context, variable);
if (value === '$today') return this.todayCompletions(context, variable); if (value === '$today') return this.todayCompletions(context, variable);
if (value === 'DateTime') return this.dateTimeCompltions(context, variable); if (value === 'DateTime') return this.dateTimeCompletions(context, variable);
// item index // item index

View file

@ -1,11 +1,12 @@
import Vue from 'vue'; import Vue from 'vue';
import { isAllowedInDotNotation, escape, toVariableOption } from '../utils'; import { escape, toVariableOption } from '../utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { IDataObject, IPinData, IRunData } from 'n8n-workflow'; import type { IDataObject, IPinData, IRunData } from 'n8n-workflow';
import type { CodeNodeEditorMixin } from '../types'; import type { CodeNodeEditorMixin } from '../types';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows'; import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv'; import { useNDVStore } from '@/stores/ndv';
import { isAllowedInDotNotation } from '@/plugins/codemirror/completions/utils';
export const jsonFieldCompletions = (Vue as CodeNodeEditorMixin).extend({ export const jsonFieldCompletions = (Vue as CodeNodeEditorMixin).extend({
computed: { computed: {

View file

@ -76,7 +76,7 @@ export const luxonCompletions = (Vue as CodeNodeEditorMixin).extend({
/** /**
* Complete `DateTime` with luxon `DateTime` static methods. * Complete `DateTime` with luxon `DateTime` static methods.
*/ */
dateTimeCompltions(context: CompletionContext, matcher = 'DateTime'): CompletionResult | null { dateTimeCompletions(context: CompletionContext, matcher = 'DateTime'): CompletionResult | null {
const pattern = new RegExp(`${escape(matcher)}\..*`); const pattern = new RegExp(`${escape(matcher)}\..*`);
const preCursor = context.matchBefore(pattern); const preCursor = context.matchBefore(pattern);

View file

@ -1,42 +0,0 @@
import { completionStatus, insertBracket } from '@codemirror/autocomplete';
import { codePointAt, codePointSize } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
/**
* Customized input handler to prevent token autoclosing in certain cases.
*
* Based on: https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79
*/
export const customInputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
if (view.composing || view.state.readOnly) return false;
// customization: do not autoclose tokens during autocompletion
if (completionStatus(view.state) !== null) return false;
const selection = view.state.selection.main;
// customization: do not autoclose square brackets prior to `.json`
if (
insert === '[' &&
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
) {
return false;
}
if (
insert.length > 2 ||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
from !== selection.from ||
to !== selection.to
) {
return false;
}
const transaction = insertBracket(view.state, insert);
if (!transaction) return false;
view.dispatch(transaction);
return true;
});

View file

@ -29,12 +29,6 @@ export function walk<T extends RangeNode>(
return found as T[]; return found as T[];
} }
export const isAllowedInDotNotation = (str: string) => {
const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g;
return !DOT_NOTATION_BANNED_CHARS.test(str);
};
export const escape = (str: string) => export const escape = (str: string) =>
str str
.replace('$', '\\$') .replace('$', '\\$')

View file

@ -10,13 +10,14 @@ import { history } from '@codemirror/commands';
import { workflowHelpers } from '@/mixins/workflowHelpers'; import { workflowHelpers } from '@/mixins/workflowHelpers';
import { expressionManager } from '@/mixins/expressionManager'; import { expressionManager } from '@/mixins/expressionManager';
import { doubleBraceHandler } from '@/plugins/codemirror/doubleBraceHandler'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport'; import { n8nLang } from '@/plugins/codemirror/n8nLang';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { inputTheme } from './theme'; import { inputTheme } from './theme';
import type { IVariableItemSelected } from '@/Interface'; import type { IVariableItemSelected } from '@/Interface';
import { forceParse } from '@/utils/forceParse'; import { forceParse } from '@/utils/forceParse';
import { autocompletion } from '@codemirror/autocomplete';
export default mixins(expressionManager, workflowHelpers).extend({ export default mixins(expressionManager, workflowHelpers).extend({
name: 'ExpressionEditorModalInput', name: 'ExpressionEditorModalInput',
@ -36,9 +37,10 @@ export default mixins(expressionManager, workflowHelpers).extend({
mounted() { mounted() {
const extensions = [ const extensions = [
inputTheme(), inputTheme(),
n8nLanguageSupport(), autocompletion(),
n8nLang(),
history(), history(),
doubleBraceHandler(), expressionInputHandler(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorState.readOnly.of(this.isReadOnly), EditorState.readOnly.of(this.isReadOnly),
EditorView.domEventHandlers({ scroll: forceParse }), EditorView.domEventHandlers({ scroll: forceParse }),

View file

@ -13,9 +13,10 @@ import { useNDVStore } from '@/stores/ndv';
import { workflowHelpers } from '@/mixins/workflowHelpers'; import { workflowHelpers } from '@/mixins/workflowHelpers';
import { expressionManager } from '@/mixins/expressionManager'; import { expressionManager } from '@/mixins/expressionManager';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { doubleBraceHandler } from '@/plugins/codemirror/doubleBraceHandler';
import { inputTheme } from './theme'; import { inputTheme } from './theme';
import { autocompletion, ifIn } from '@codemirror/autocomplete';
import { n8nLang } from '@/plugins/codemirror/n8nLang';
export default mixins(expressionManager, workflowHelpers).extend({ export default mixins(expressionManager, workflowHelpers).extend({
name: 'InlineExpressionEditorInput', name: 'InlineExpressionEditorInput',
@ -39,35 +40,19 @@ export default mixins(expressionManager, workflowHelpers).extend({
}, },
watch: { watch: {
value(newValue) { value(newValue) {
const payload: Record<string, unknown> = { const isInternalChange = newValue === this.editor?.state.doc.toString();
if (isInternalChange) return;
// manual update on external change, e.g. from expression modal or mapping drop
this.editor?.dispatch({
changes: { changes: {
from: 0, from: 0,
to: this.editor?.state.doc.length, to: this.editor?.state.doc.length,
insert: newValue, insert: newValue,
}, },
selection: { anchor: this.cursorPosition, head: this.cursorPosition }, });
};
/**
* If completion from selection, preserve selection.
*/
if (this.editor) {
const [range] = this.editor.state.selection.ranges;
const isBraceAutoinsertion =
this.editor.state.sliceDoc(range.from - 1, range.from) === '{' &&
this.editor.state.sliceDoc(range.to, range.to + 1) === '}';
if (isBraceAutoinsertion) {
payload.selection = { anchor: range.from, head: range.to };
}
}
try {
this.editor?.dispatch(payload);
} catch (_) {
// ignore out-of-range selection error on drop
}
}, },
ndvInputData() { ndvInputData() {
this.editor?.dispatch({ this.editor?.dispatch({
@ -92,9 +77,10 @@ export default mixins(expressionManager, workflowHelpers).extend({
mounted() { mounted() {
const extensions = [ const extensions = [
inputTheme({ isSingleLine: this.isSingleLine }), inputTheme({ isSingleLine: this.isSingleLine }),
n8nLanguageSupport(), autocompletion(),
n8nLang(),
history(), history(),
doubleBraceHandler(), expressionInputHandler(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.editable.of(!this.isReadOnly), EditorView.editable.of(!this.isReadOnly),
EditorView.domEventHandlers({ EditorView.domEventHandlers({

View file

@ -69,29 +69,297 @@ import { ICredentialsResponse } from '@/Interface';
let cachedWorkflowKey: string | null = ''; let cachedWorkflowKey: string | null = '';
let cachedWorkflow: Workflow | null = null; let cachedWorkflow: Workflow | null = null;
export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showMessage).extend({ export function resolveParameter(
computed: { parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
...mapStores( opts: {
useNodeTypesStore, targetItem?: TargetItem;
useNDVStore, inputNodeName?: string;
useRootStore, inputRunIndex?: number;
useTemplatesStore, inputBranchIndex?: number;
useWorkflowsStore, } = {},
useWorkflowsEEStore, ): IDataObject | null {
useUsersStore, let itemIndex = opts?.targetItem?.itemIndex || 0;
useUIStore,
), const inputName = 'main';
workflowPermissions(): IPermissions { const activeNode = useNDVStore().activeNode;
return getWorkflowPermissions(this.usersStore.currentUser, this.workflowsStore.workflow); const workflow = getCurrentWorkflow();
const workflowRunData = useWorkflowsStore().getWorkflowRunData;
let parentNode = workflow.getParentNodes(activeNode!.name, inputName, 1);
const executionData = useWorkflowsStore().getWorkflowExecution;
if (opts?.inputNodeName && !parentNode.includes(opts.inputNodeName)) {
return null;
}
let runIndexParent = opts?.inputRunIndex ?? 0;
const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]);
if (opts.targetItem && opts?.targetItem?.nodeName === activeNode!.name && executionData) {
const sourceItems = getSourceItems(executionData, opts.targetItem);
if (!sourceItems.length) {
return null;
}
parentNode = [sourceItems[0].nodeName];
runIndexParent = sourceItems[0].runIndex;
itemIndex = sourceItems[0].itemIndex;
if (nodeConnection) {
nodeConnection.sourceIndex = sourceItems[0].outputIndex;
}
} else {
parentNode = opts.inputNodeName ? [opts.inputNodeName] : parentNode;
if (nodeConnection) {
nodeConnection.sourceIndex = opts.inputBranchIndex ?? nodeConnection.sourceIndex;
}
if (opts?.inputRunIndex === undefined && workflowRunData !== null && parentNode.length) {
const firstParentWithWorkflowRunData = parentNode.find(
(parentNodeName) => workflowRunData[parentNodeName],
);
if (firstParentWithWorkflowRunData) {
runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1;
}
}
}
let _connectionInputData = connectionInputData(
parentNode,
activeNode!.name,
inputName,
runIndexParent,
nodeConnection,
);
let runExecutionData: IRunExecutionData;
if (executionData === null || !executionData.data) {
runExecutionData = {
resultData: {
runData: {},
},
};
} else {
runExecutionData = executionData.data;
}
parentNode.forEach((parentNodeName) => {
const pinData: IPinData[string] | undefined =
useWorkflowsStore().pinDataByNodeName(parentNodeName);
if (pinData) {
runExecutionData = {
...runExecutionData,
resultData: {
...runExecutionData.resultData,
runData: {
...runExecutionData.resultData.runData,
[parentNodeName]: [
{
startTime: new Date().valueOf(),
executionTime: 0,
source: [],
data: {
main: [pinData.map((data) => ({ json: data }))],
}, },
}, },
methods: { ],
executeData( },
},
};
}
});
if (_connectionInputData === null) {
_connectionInputData = [];
}
const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
$execution: {
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
mode: 'test',
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
// deprecated
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
};
let runIndexCurrent = opts?.targetItem?.runIndex ?? 0;
if (
opts?.targetItem === undefined &&
workflowRunData !== null &&
workflowRunData[activeNode!.name]
) {
runIndexCurrent = workflowRunData[activeNode!.name].length - 1;
}
const _executeData = executeData(parentNode, activeNode!.name, inputName, runIndexCurrent);
return workflow.expression.getParameterValue(
parameter,
runExecutionData,
runIndexCurrent,
itemIndex,
activeNode!.name,
_connectionInputData,
'manual',
useRootStore().timezone,
additionalKeys,
_executeData,
false,
) as IDataObject;
}
function getCurrentWorkflow(copyData?: boolean): Workflow {
const nodes = getNodes();
const connections = useWorkflowsStore().allConnections;
const cacheKey = JSON.stringify({ nodes, connections });
if (!copyData && cachedWorkflow && cacheKey === cachedWorkflowKey) {
return cachedWorkflow;
}
cachedWorkflowKey = cacheKey;
return getWorkflow(nodes, connections, copyData);
}
// Returns a shallow copy of the nodes which means that all the data on the lower
// levels still only gets referenced but the top level object is a different one.
// This has the advantage that it is very fast and does not cause problems with vuex
// when the workflow replaces the node-parameters.
function getNodes(): INodeUi[] {
const nodes = useWorkflowsStore().allNodes;
const returnNodes: INodeUi[] = [];
for (const node of nodes) {
returnNodes.push(Object.assign({}, node));
}
return returnNodes;
}
// Returns a workflow instance.
function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow {
const nodeTypes = getNodeTypes();
let workflowId: string | undefined = useWorkflowsStore().workflowId;
if (workflowId && workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
workflowId = undefined;
}
const workflowName = useWorkflowsStore().workflowName;
cachedWorkflow = new Workflow({
id: workflowId,
name: workflowName,
nodes: copyData ? deepCopy(nodes) : nodes,
connections: copyData ? deepCopy(connections) : connections,
active: false,
nodeTypes,
settings: useWorkflowsStore().workflowSettings,
// @ts-ignore
pinData: useWorkflowsStore().pinData,
});
return cachedWorkflow;
}
function getNodeTypes(): INodeTypes {
const nodeTypes: INodeTypes = {
nodeTypes: {},
init: async (nodeTypes?: INodeTypeData): Promise<void> => {},
// @ts-ignore
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
const nodeTypeDescription = useNodeTypesStore().getNodeType(nodeType, version);
if (nodeTypeDescription === null) {
return undefined;
}
return {
description: nodeTypeDescription,
// As we do not have the trigger/poll functions available in the frontend
// we use the information available to figure out what are trigger nodes
// @ts-ignore
trigger:
(![ERROR_TRIGGER_NODE_TYPE, START_NODE_TYPE].includes(nodeType) &&
nodeTypeDescription.inputs.length === 0 &&
!nodeTypeDescription.webhooks) ||
undefined,
};
},
};
return nodeTypes;
}
// Returns connectionInputData to be able to execute an expression.
function connectionInputData(
parentNode: string[], parentNode: string[],
currentNode: string, currentNode: string,
inputName: string, inputName: string,
runIndex: number, runIndex: number,
): IExecuteData { nodeConnection: INodeConnection = { sourceIndex: 0, destinationIndex: 0 },
): INodeExecutionData[] | null {
let connectionInputData: INodeExecutionData[] | null = null;
const _executeData = executeData(parentNode, currentNode, inputName, runIndex);
if (parentNode.length) {
if (
!Object.keys(_executeData.data).length ||
_executeData.data[inputName].length <= nodeConnection.sourceIndex
) {
connectionInputData = [];
} else {
connectionInputData = _executeData.data![inputName][nodeConnection.sourceIndex];
if (connectionInputData !== null) {
// Update the pairedItem information on items
connectionInputData = connectionInputData.map((item, itemIndex) => {
return {
...item,
pairedItem: {
item: itemIndex,
input: nodeConnection.destinationIndex,
},
};
});
}
}
}
const parentPinData = parentNode.reduce((acc: INodeExecutionData[], parentNodeName, index) => {
const pinData = useWorkflowsStore().pinDataByNodeName(parentNodeName);
if (pinData) {
acc.push({
json: pinData[0],
pairedItem: {
item: index,
input: 1,
},
});
}
return acc;
}, []);
if (parentPinData.length > 0) {
if (connectionInputData && connectionInputData.length > 0) {
parentPinData.forEach((parentPinDataEntry) => {
connectionInputData![0].json = {
...connectionInputData![0].json,
...parentPinDataEntry.json,
};
});
} else {
connectionInputData = parentPinData;
}
}
return connectionInputData;
}
function executeData(
parentNode: string[],
currentNode: string,
inputName: string,
runIndex: number,
): IExecuteData {
const executeData = { const executeData = {
node: {}, node: {},
data: {}, data: {},
@ -103,7 +371,7 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
// which does not use the node name // which does not use the node name
const parentNodeName = parentNode[0]; const parentNodeName = parentNode[0];
const parentPinData = this.workflowsStore.getPinData![parentNodeName]; const parentPinData = useWorkflowsStore().getPinData![parentNodeName];
// populate `executeData` from `pinData` // populate `executeData` from `pinData`
@ -116,7 +384,7 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
// populate `executeData` from `runData` // populate `executeData` from `runData`
const workflowRunData = this.workflowsStore.getWorkflowRunData; const workflowRunData = useWorkflowsStore().getWorkflowRunData;
if (workflowRunData === null) { if (workflowRunData === null) {
return executeData; return executeData;
} }
@ -150,90 +418,32 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
} }
return executeData; return executeData;
}
export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showMessage).extend({
computed: {
...mapStores(
useNodeTypesStore,
useNDVStore,
useRootStore,
useTemplatesStore,
useWorkflowsStore,
useWorkflowsEEStore,
useUsersStore,
useUIStore,
),
workflowPermissions(): IPermissions {
return getWorkflowPermissions(this.usersStore.currentUser, this.workflowsStore.workflow);
}, },
// Returns connectionInputData to be able to execute an expression.
connectionInputData(
parentNode: string[],
currentNode: string,
inputName: string,
runIndex: number,
nodeConnection: INodeConnection = { sourceIndex: 0, destinationIndex: 0 },
): INodeExecutionData[] | null {
let connectionInputData: INodeExecutionData[] | null = null;
const executeData = this.executeData(parentNode, currentNode, inputName, runIndex);
if (parentNode.length) {
if (
!Object.keys(executeData.data).length ||
executeData.data[inputName].length <= nodeConnection.sourceIndex
) {
connectionInputData = [];
} else {
connectionInputData = executeData.data![inputName][nodeConnection.sourceIndex];
if (connectionInputData !== null) {
// Update the pairedItem information on items
connectionInputData = connectionInputData.map((item, itemIndex) => {
return {
...item,
pairedItem: {
item: itemIndex,
input: nodeConnection.destinationIndex,
},
};
});
}
}
}
const parentPinData = parentNode.reduce(
(acc: INodeExecutionData[], parentNodeName, index) => {
const pinData = this.workflowsStore.pinDataByNodeName(parentNodeName);
if (pinData) {
acc.push({
json: pinData[0],
pairedItem: {
item: index,
input: 1,
},
});
}
return acc;
},
[],
);
if (parentPinData.length > 0) {
if (connectionInputData && connectionInputData.length > 0) {
parentPinData.forEach((parentPinDataEntry) => {
connectionInputData![0].json = {
...connectionInputData![0].json,
...parentPinDataEntry.json,
};
});
} else {
connectionInputData = parentPinData;
}
}
return connectionInputData;
},
// Returns a shallow copy of the nodes which means that all the data on the lower
// levels still only gets referenced but the top level object is a different one.
// This has the advantage that it is very fast and does not cause problems with vuex
// when the workflow replaces the node-parameters.
getNodes(): INodeUi[] {
const nodes = this.workflowsStore.allNodes;
const returnNodes: INodeUi[] = [];
for (const node of nodes) {
returnNodes.push(Object.assign({}, node));
}
return returnNodes;
}, },
methods: {
resolveParameter,
getCurrentWorkflow,
getNodes,
getWorkflow,
getNodeTypes,
connectionInputData,
executeData,
// Returns data about nodeTypes which have a "maxNodes" limit set. // Returns data about nodeTypes which have a "maxNodes" limit set.
// For each such type does it return how high the limit is, how many // For each such type does it return how high the limit is, how many
@ -347,70 +557,6 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
return workflowIssues; return workflowIssues;
}, },
getNodeTypes(): INodeTypes {
const nodeTypes: INodeTypes = {
nodeTypes: {},
init: async (nodeTypes?: INodeTypeData): Promise<void> => {},
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
const nodeTypeDescription = this.nodeTypesStore.getNodeType(nodeType, version);
if (nodeTypeDescription === null) {
return undefined;
}
return {
description: nodeTypeDescription,
// As we do not have the trigger/poll functions available in the frontend
// we use the information available to figure out what are trigger nodes
// @ts-ignore
trigger:
(![ERROR_TRIGGER_NODE_TYPE, START_NODE_TYPE].includes(nodeType) &&
nodeTypeDescription.inputs.length === 0 &&
!nodeTypeDescription.webhooks) ||
undefined,
};
},
};
return nodeTypes;
},
getCurrentWorkflow(copyData?: boolean): Workflow {
const nodes = this.getNodes();
const connections = this.workflowsStore.allConnections;
const cacheKey = JSON.stringify({ nodes, connections });
if (!copyData && cachedWorkflow && cacheKey === cachedWorkflowKey) {
return cachedWorkflow;
}
cachedWorkflowKey = cacheKey;
return this.getWorkflow(nodes, connections, copyData);
},
// Returns a workflow instance.
getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow {
const nodeTypes = this.getNodeTypes();
let workflowId: string | undefined = this.workflowsStore.workflowId;
if (workflowId && workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
workflowId = undefined;
}
const workflowName = this.workflowsStore.workflowName;
cachedWorkflow = new Workflow({
id: workflowId,
name: workflowName,
nodes: copyData ? deepCopy(nodes) : nodes,
connections: copyData ? deepCopy(connections) : connections,
active: false,
nodeTypes,
settings: this.workflowsStore.workflowSettings,
pinData: this.workflowsStore.pinData,
});
return cachedWorkflow;
},
// Returns the currently loaded workflow as JSON. // Returns the currently loaded workflow as JSON.
getWorkflowDataToSave(): Promise<IWorkflowData> { getWorkflowDataToSave(): Promise<IWorkflowData> {
const workflowNodes = this.workflowsStore.allNodes; const workflowNodes = this.workflowsStore.allNodes;
@ -579,143 +725,6 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, node, path, isFullPath); return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, node, path, isFullPath);
}, },
resolveParameter(
parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
opts: {
targetItem?: TargetItem;
inputNodeName?: string;
inputRunIndex?: number;
inputBranchIndex?: number;
} = {},
): IDataObject | null {
let itemIndex = opts?.targetItem?.itemIndex || 0;
const inputName = 'main';
const activeNode = this.ndvStore.activeNode;
const workflow = this.getCurrentWorkflow();
const workflowRunData = this.workflowsStore.getWorkflowRunData;
let parentNode = workflow.getParentNodes(activeNode?.name, inputName, 1);
const executionData = this.workflowsStore.getWorkflowExecution;
if (opts?.inputNodeName && !parentNode.includes(opts.inputNodeName)) {
return null;
}
let runIndexParent = opts?.inputRunIndex ?? 0;
const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]);
if (opts.targetItem && opts?.targetItem?.nodeName === activeNode.name && executionData) {
const sourceItems = getSourceItems(executionData, opts.targetItem);
if (!sourceItems.length) {
return null;
}
parentNode = [sourceItems[0].nodeName];
runIndexParent = sourceItems[0].runIndex;
itemIndex = sourceItems[0].itemIndex;
if (nodeConnection) {
nodeConnection.sourceIndex = sourceItems[0].outputIndex;
}
} else {
parentNode = opts.inputNodeName ? [opts.inputNodeName] : parentNode;
if (nodeConnection) {
nodeConnection.sourceIndex = opts.inputBranchIndex ?? nodeConnection.sourceIndex;
}
if (opts?.inputRunIndex === undefined && workflowRunData !== null && parentNode.length) {
const firstParentWithWorkflowRunData = parentNode.find(
(parentNodeName) => workflowRunData[parentNodeName],
);
if (firstParentWithWorkflowRunData) {
runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1;
}
}
}
let connectionInputData = this.connectionInputData(
parentNode,
activeNode.name,
inputName,
runIndexParent,
nodeConnection,
);
let runExecutionData: IRunExecutionData;
if (executionData === null || !executionData.data) {
runExecutionData = {
resultData: {
runData: {},
},
};
} else {
runExecutionData = executionData.data;
}
parentNode.forEach((parentNodeName) => {
const pinData: IPinData[string] = this.workflowsStore.pinDataByNodeName(parentNodeName);
if (pinData) {
runExecutionData = {
...runExecutionData,
resultData: {
...runExecutionData.resultData,
runData: {
...runExecutionData.resultData.runData,
[parentNodeName]: [
{
startTime: new Date().valueOf(),
executionTime: 0,
source: [],
data: {
main: [pinData.map((data) => ({ json: data }))],
},
},
],
},
},
};
}
});
if (connectionInputData === null) {
connectionInputData = [];
}
const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
$execution: {
id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
mode: 'test',
resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
},
// deprecated
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
};
let runIndexCurrent = opts?.targetItem?.runIndex ?? 0;
if (
opts?.targetItem === undefined &&
workflowRunData !== null &&
workflowRunData[activeNode.name]
) {
runIndexCurrent = workflowRunData[activeNode.name].length - 1;
}
const executeData = this.executeData(parentNode, activeNode.name, inputName, runIndexCurrent);
return workflow.expression.getParameterValue(
parameter,
runExecutionData,
runIndexCurrent,
itemIndex,
activeNode.name,
connectionInputData,
'manual',
this.rootStore.timezone,
additionalKeys,
executeData,
false,
) as IDataObject;
},
resolveExpression( resolveExpression(
expression: string, expression: string,
siblingParameters: INodeParameters = {}, siblingParameters: INodeParameters = {},
@ -731,13 +740,13 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
__xxxxxxx__: expression, __xxxxxxx__: expression,
...siblingParameters, ...siblingParameters,
}; };
const returnData: IDataObject | null = this.resolveParameter(parameters, opts); const returnData: IDataObject | null = resolveParameter(parameters, opts);
if (!returnData) { if (!returnData) {
return null; return null;
} }
if (typeof returnData['__xxxxxxx__'] === 'object') { if (typeof returnData['__xxxxxxx__'] === 'object') {
const workflow = this.getCurrentWorkflow(); const workflow = getCurrentWorkflow();
return workflow.expression.convertObjectValueToString(returnData['__xxxxxxx__'] as object); return workflow.expression.convertObjectValueToString(returnData['__xxxxxxx__'] as object);
} }
return returnData['__xxxxxxx__']; return returnData['__xxxxxxx__'];
@ -980,7 +989,7 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
this.uiStore.stateIsDirty = false; this.uiStore.stateIsDirty = false;
this.$externalHooks().run('workflow.afterUpdate', { workflowData }); this.$externalHooks().run('workflow.afterUpdate', { workflowData });
this.getCurrentWorkflow(true); // refresh cache getCurrentWorkflow(true); // refresh cache
return true; return true;
} catch (e) { } catch (e) {
this.uiStore.removeActiveAction('workflowSaving'); this.uiStore.removeActiveAction('workflowSaving');

View file

@ -0,0 +1,30 @@
import { alphaCompletions } from '../alpha.completions';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
const EXPLICIT = false;
test('should return alphabetic char completion options: D', () => {
const doc = '{{ D }}';
const position = doc.indexOf('D') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = alphaCompletions(context);
if (!result) throw new Error('Expected D completion options');
const { options, from } = result;
expect(options.map((o) => o.label)).toEqual(['DateTime']);
expect(from).toEqual(position - 1);
});
test('should not return alphabetic char completion options: $input.D', () => {
const doc = '{{ $input.D }}';
const position = doc.indexOf('D') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = alphaCompletions(context);
expect(result).toBeNull();
});

View file

@ -0,0 +1,37 @@
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { dateTimeOptions, nowTodayOptions, luxonCompletions } from '../luxon.completions';
const EXPLICIT = false;
test('should return luxon completion options: $now, $today', () => {
['$now', '$today'].forEach((luxonVar) => {
const doc = `{{ ${luxonVar}. }}`;
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = luxonCompletions(context);
if (!result) throw new Error(`Expected luxon ${luxonVar} completion options`);
const { options, from } = result;
expect(options.map((o) => o.label)).toEqual(nowTodayOptions().map((o) => o.label));
expect(from).toEqual(position);
});
});
test('should return luxon completion options: DateTime', () => {
const doc = '{{ DateTime. }}';
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = luxonCompletions(context);
if (!result) throw new Error('Expected luxon completion options');
const { options, from } = result;
expect(options.map((o) => o.label)).toEqual(dateTimeOptions().map((o) => o.label));
expect(from).toEqual(position);
});

View file

@ -0,0 +1,146 @@
import { proxyCompletions } from '../proxy.completions';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import * as workflowHelpers from '@/mixins/workflowHelpers';
import {
executionProxy,
inputProxy,
itemProxy,
nodeSelectorProxy,
prevNodeProxy,
workflowProxy,
} from './proxyMocks';
import { IDataObject } from 'n8n-workflow';
const EXPLICIT = false;
beforeEach(() => {
setActivePinia(createTestingPinia());
});
function testCompletionOptions(proxy: IDataObject, toResolve: string) {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(proxy);
const doc = `{{ ${toResolve}. }}`;
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = proxyCompletions(context);
if (!result) throw new Error(`Expected ${toResolve} completion options`);
const { options: actual, from } = result;
expect(actual.map((o) => o.label)).toEqual(Reflect.ownKeys(proxy));
expect(from).toEqual(position);
}
// input proxy
test('should return proxy completion options: $input', () => {
testCompletionOptions(inputProxy, '$input');
});
// item proxy
test('should return proxy completion options: $input.first()', () => {
testCompletionOptions(itemProxy, '$input.first()');
});
test('should return proxy completion options: $input.last()', () => {
testCompletionOptions(itemProxy, '$input.last()');
});
test('should return proxy completion options: $input.item', () => {
testCompletionOptions(itemProxy, '$input.item');
});
test('should return proxy completion options: $input.all()[0]', () => {
testCompletionOptions(itemProxy, '$input.all()[0]');
});
// json proxy
test('should return proxy completion options: $json', () => {
testCompletionOptions(workflowProxy, '$json');
});
// prevNode proxy
test('should return proxy completion options: $prevNode', () => {
testCompletionOptions(prevNodeProxy, '$prevNode');
});
// execution proxy
test('should return proxy completion options: $execution', () => {
testCompletionOptions(executionProxy, '$execution');
});
// workflow proxy
test('should return proxy completion options: $workflow', () => {
testCompletionOptions(workflowProxy, '$workflow');
});
// node selector proxy
test('should return proxy completion options: $()', () => {
const firstNodeName = 'Manual';
const secondNodeName = 'Set';
const nodes = [
{
id: uuidv4(),
name: firstNodeName,
position: [0, 0],
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
},
{
id: uuidv4(),
name: secondNodeName,
position: [0, 0],
type: 'n8n-nodes-base.set',
typeVersion: 1,
},
];
const connections = {
Manual: {
main: [
[
{
node: 'Set',
type: 'main',
index: 0,
},
],
],
},
};
const initialState = { workflows: { workflow: { nodes, connections } } };
setActivePinia(createTestingPinia({ initialState }));
testCompletionOptions(nodeSelectorProxy, "$('Set')");
});
// no proxy
test('should not return completion options for non-existing proxies', () => {
vi.spyOn(workflowHelpers, 'resolveParameter').mockReturnValueOnce(null);
const doc = '{{ $hello. }}';
const position = doc.indexOf('.') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = proxyCompletions(context);
expect(result).toBeNull();
});

View file

@ -0,0 +1,98 @@
export const inputProxy = new Proxy(
{},
{
ownKeys() {
return ['all', 'context', 'first', 'item', 'last', 'params'];
},
get(_, property) {
if (property === 'all') return [];
if (property === 'context') return {};
if (property === 'first') return {};
if (property === 'item') return {};
if (property === 'last') return {};
if (property === 'params') return {};
return undefined;
},
},
);
export const nodeSelectorProxy = new Proxy(
{},
{
ownKeys() {
return ['all', 'context', 'first', 'item', 'last', 'params', 'pairedItem', 'itemMatching'];
},
get(_, property) {
if (property === 'all') return [];
if (property === 'context') return {};
if (property === 'first') return {};
if (property === 'item') return {};
if (property === 'last') return {};
if (property === 'params') return {};
if (property === 'pairedItem') return {};
if (property === 'itemMatching') return {};
return undefined;
},
},
);
export const itemProxy = new Proxy(
{ json: {}, pairedItem: {} },
{
get(_, property) {
if (property === 'json') return {};
return undefined;
},
},
);
export const prevNodeProxy = new Proxy(
{},
{
ownKeys() {
return ['name', 'outputIndex', 'runIndex'];
},
get(_, property) {
if (property === 'name') return '';
if (property === 'outputIndex') return 0;
if (property === 'runIndex') return 0;
return undefined;
},
},
);
export const executionProxy = new Proxy(
{},
{
ownKeys() {
return ['id', 'mode', 'resumeUrl'];
},
get(_, property) {
if (property === 'id') return '';
if (property === 'mode') return '';
if (property === 'resumeUrl') return '';
return undefined;
},
},
);
export const workflowProxy = new Proxy(
{},
{
ownKeys() {
return ['active', 'id', 'name'];
},
get(_, property) {
if (property === 'active') return false;
if (property === 'id') return '';
if (property === 'name') return '';
return undefined;
},
},
);

View file

@ -0,0 +1,80 @@
import { rootCompletions } from '../root.completions';
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { v4 as uuidv4 } from 'uuid';
import { i18n } from '@/plugins/i18n';
const EXPLICIT = false;
test('should return completion options: $', () => {
setActivePinia(createTestingPinia());
const doc = '{{ $ }}';
const position = doc.indexOf('$') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = rootCompletions(context);
if (!result) throw new Error('Expected dollar-sign completion options');
const { options, from } = result;
const rootKeys = [...Object.keys(i18n.rootVars), '$parameter'].sort((a, b) => a.localeCompare(b));
expect(options.map((o) => o.label)).toEqual(rootKeys);
expect(from).toEqual(position - 1);
});
test('should return completion options: $(', () => {
const firstNodeName = 'Manual Trigger';
const secondNodeName = 'Set';
const nodes = [
{
id: uuidv4(),
name: firstNodeName,
position: [0, 0],
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
},
{
id: uuidv4(),
name: secondNodeName,
position: [0, 0],
type: 'n8n-nodes-base.set',
typeVersion: 1,
},
];
const initialState = { workflows: { workflow: { nodes } } };
setActivePinia(createTestingPinia({ initialState }));
const doc = '{{ $( }}';
const position = doc.indexOf('(') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = rootCompletions(context);
if (!result) throw new Error('Expected dollar-sign-selector completion options');
const { options, from } = result;
expect(options).toHaveLength(nodes.length);
expect(options[0].label).toEqual(`$('${firstNodeName}')`);
expect(options[1].label).toEqual(`$('${secondNodeName}')`);
expect(from).toEqual(position - 2);
});
test('should not return completion options for regular strings', () => {
setActivePinia(createTestingPinia());
const doc = '{{ hello }}';
const position = doc.indexOf('o') + 1;
const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT);
const result = rootCompletions(context);
expect(result).toBeNull();
});

View file

@ -0,0 +1,50 @@
import { i18n } from '@/plugins/i18n';
import { longestCommonPrefix } from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
/**
* Completions from alphabetic char, e.g. `D` -> `DateTime`.
*/
export function alphaCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/(\s+)D[ateTim]*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
let options = generateOptions();
const userInput = word.text.trim();
if (userInput !== '' && userInput !== '$') {
options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label);
}
return {
from: word.to - userInput.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInput, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions() {
const emptyKeys = ['DateTime'];
return emptyKeys.map((key) => {
const option: Completion = {
label: key,
type: key.endsWith('()') ? 'function' : 'keyword',
};
const info = i18n.rootVars[key];
if (info) option.info = info;
return option;
});
}

View file

@ -0,0 +1,84 @@
import { i18n } from '@/plugins/i18n';
import { longestCommonPrefix } from './utils';
import { DateTime } from 'luxon';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
export function luxonCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/(DateTime|\$(now|today)*)\.(\w|\.|\(|\))*/); //
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
const toResolve = word.text.endsWith('.')
? word.text.slice(0, -1)
: word.text.split('.').slice(0, -1).join('.');
let options = generateOptions(toResolve);
const userInputTail = word.text.split('.').pop();
if (userInputTail === undefined) return null;
if (userInputTail !== '') {
options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label);
}
return {
from: word.to - userInputTail.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInputTail, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions(toResolve: string): Completion[] {
if (toResolve === '$now' || toResolve === '$today') return nowTodayOptions();
if (toResolve === 'DateTime') return dateTimeOptions();
return [];
}
export const nowTodayOptions = () => {
const SKIP_SET = new Set(['constructor', 'get']);
const entries = Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype))
.filter(([key]) => !SKIP_SET.has(key))
.sort(([a], [b]) => a.localeCompare(b));
return entries.map(([key, descriptor]) => {
const isFunction = typeof descriptor.value === 'function';
const option: Completion = {
label: isFunction ? `${key}()` : key,
type: isFunction ? 'function' : 'keyword',
};
const info = i18n.luxonInstance[key];
if (info) option.info = info;
return option;
});
};
export const dateTimeOptions = () => {
const SKIP_SET = new Set(['prototype', 'name', 'length']);
const keys = Object.keys(Object.getOwnPropertyDescriptors(DateTime))
.filter((key) => !SKIP_SET.has(key) && !key.includes('_'))
.sort((a, b) => a.localeCompare(b));
return keys.map((key) => {
const option: Completion = { label: `${key}()`, type: 'function' };
const info = i18n.luxonStatic[key];
if (info) option.info = info;
return option;
});
};

View file

@ -0,0 +1,106 @@
import { i18n } from '@/plugins/i18n';
import { resolveParameter } from '@/mixins/workflowHelpers';
import { isAllowedInDotNotation, longestCommonPrefix } from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import type { IDataObject } from 'n8n-workflow';
import type { Word } from '@/types/completions';
/**
* Completions from proxies to their content.
*/
export function proxyCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(
/\$(input|\(.+\)|prevNode|parameter|json|execution|workflow)*(\.|\[)(\w|\W)*/,
);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
const toResolve = word.text.endsWith('.')
? word.text.slice(0, -1)
: word.text.split('.').slice(0, -1).join('.');
let options: Completion[] = [];
try {
const proxy = resolveParameter(`={{ ${toResolve} }}`);
if (!proxy || typeof proxy !== 'object' || Array.isArray(proxy)) return null;
options = generateOptions(toResolve, proxy, word);
} catch (_) {
return null;
}
let userInputTail = '';
const delimiter = word.text.includes('json[') ? 'json[' : '.';
userInputTail = word.text.split(delimiter).pop() as string;
if (userInputTail !== '') {
options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label);
}
return {
from: word.to - userInputTail.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInputTail, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Completion[] {
const SKIP_SET = new Set(['__ob__']);
if (word.text.includes('json[')) {
return Object.keys(proxy.json as object)
.filter((key) => !SKIP_SET.has(key))
.map((key) => {
return {
label: `'${key}']`,
type: 'keyword',
};
});
}
const proxyName = toResolve.startsWith('$(') ? '$()' : toResolve;
return (Reflect.ownKeys(proxy) as string[])
.filter((key) => {
if (word.text.endsWith('json.')) return !SKIP_SET.has(key) && isAllowedInDotNotation(key);
return !SKIP_SET.has(key);
})
.map((key) => {
ensureKeyCanBeResolved(proxy, key);
const isFunction = typeof proxy[key] === 'function';
const option: Completion = {
label: isFunction ? `${key}()` : key,
type: isFunction ? 'function' : 'keyword',
};
const infoKey = [proxyName, key].join('.');
const info = i18n.proxyVars[infoKey];
if (info) option.info = info;
return option;
});
}
function ensureKeyCanBeResolved(proxy: IDataObject, key: string) {
try {
proxy[key];
} catch (error) {
// e.g. attempting to access non-parent node with `$()`
throw new Error('Cannot generate options', { cause: error });
}
}

View file

@ -0,0 +1,60 @@
import { i18n } from '@/plugins/i18n';
import { autocompletableNodeNames, longestCommonPrefix } from './utils';
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
/**
* Completions from `$` to proxies.
*/
export function rootCompletions(context: CompletionContext): CompletionResult | null {
const word = context.matchBefore(/\$\w*[^.]*/);
if (!word) return null;
if (word.from === word.to && !context.explicit) return null;
let options = generateOptions();
const { text: userInput } = word;
if (userInput !== '' && userInput !== '$') {
options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label);
}
return {
from: word.to - userInput.length,
options,
filter: false,
getMatch(completion: Completion) {
const lcp = longestCommonPrefix([userInput, completion.label]);
return [0, lcp.length];
},
};
}
function generateOptions() {
const rootKeys = [...Object.keys(i18n.rootVars), '$parameter'].sort((a, b) => a.localeCompare(b));
const options: Completion[] = rootKeys.map((key) => {
const option: Completion = {
label: key,
type: key.endsWith('()') ? 'function' : 'keyword',
};
const info = i18n.rootVars[key];
if (info) option.info = info;
return option;
});
options.push(
...autocompletableNodeNames().map((nodeName) => ({
label: `$('${nodeName}')`,
type: 'keyword',
info: i18n.baseText('codeNodeEditor.completer.$()', { interpolate: { nodeName } }),
})),
);
return options;
}

View file

@ -0,0 +1,31 @@
import { NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION } from '@/components/CodeNodeEditor/constants';
import { useWorkflowsStore } from '@/stores/workflows';
export function autocompletableNodeNames() {
return useWorkflowsStore()
.allNodes.filter((node) => !NODE_TYPES_EXCLUDED_FROM_AUTOCOMPLETION.includes(node.type))
.map((node) => node.name);
}
export const longestCommonPrefix = (strings: string[]) => {
if (strings.length === 0) return '';
return strings.reduce((acc, next) => {
let i = 0;
while (acc[i] && next[i] && acc[i] === next[i]) {
i++;
}
return acc.slice(0, i);
});
};
/**
* Whether a string may be used as a key in object dot notation access.
*/
export const isAllowedInDotNotation = (str: string) => {
const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g;
return !DOT_NOTATION_BANNED_CHARS.test(str);
};

View file

@ -0,0 +1,49 @@
import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete';
import { codePointAt, codePointSize, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
const handler = EditorView.inputHandler.of((view, from, to, insert) => {
if (view.composing || view.state.readOnly) return false;
// customization: do not autoclose tokens while autocompletion is active
if (completionStatus(view.state) !== null) return false;
const selection = view.state.selection.main;
// customization: do not autoclose square brackets prior to `.json`
if (
insert === '[' &&
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
) {
return false;
}
if (
insert.length > 2 ||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
from !== selection.from ||
to !== selection.to
) {
return false;
}
const transaction = insertBracket(view.state, insert);
if (!transaction) return false;
view.dispatch(transaction);
return true;
});
const [_, bracketState] = closeBrackets() as readonly Extension[];
/**
* CodeMirror plugin for code node editor:
*
* - prevent token autoclosing during autocompletion
* - prevent square bracket autoclosing prior to `.json`
*
* Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79).
*/
export const codeInputHandler = () => [handler, bracketState];

View file

@ -1,12 +1,23 @@
import { closeBrackets, insertBracket } from '@codemirror/autocomplete'; import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete';
import { codePointAt, codePointSize, Extension } from '@codemirror/state'; import { codePointAt, codePointSize, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view'; import { EditorView } from '@codemirror/view';
const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => { const handler = EditorView.inputHandler.of((view, from, to, insert) => {
if (view.composing || view.state.readOnly) return false; if (view.composing || view.state.readOnly) return false;
// customization: do not autoclose tokens while autocompletion is active
if (completionStatus(view.state) !== null) return false;
const selection = view.state.selection.main; const selection = view.state.selection.main;
// customization: do not autoclose square brackets prior to `.json`
if (
insert === '[' &&
view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json'
) {
return false;
}
if ( if (
insert.length > 2 || insert.length > 2 ||
(insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) || (insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) ||
@ -22,14 +33,10 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
view.dispatch(transaction); view.dispatch(transaction);
/** // customization: inject whitespace and second brace for brace completion: {| } -> {{ | }}
* Customizations to inject whitespace and braces for setup and completion
*/
const cursor = view.state.selection.main.head; const cursor = view.state.selection.main.head;
// inject whitespace and second brace for brace completion: {| } -> {{ | }}
const isBraceCompletion = const isBraceCompletion =
view.state.sliceDoc(cursor - 2, cursor) === '{{' && view.state.sliceDoc(cursor - 2, cursor) === '{{' &&
view.state.sliceDoc(cursor, cursor + 1) === '}'; view.state.sliceDoc(cursor, cursor + 1) === '}';
@ -43,7 +50,7 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
return true; return true;
} }
// inject whitespace for brace setup: empty -> {| } // customization: inject whitespace for brace setup: empty -> {| }
const isBraceSetup = const isBraceSetup =
view.state.sliceDoc(cursor - 1, cursor) === '{' && view.state.sliceDoc(cursor - 1, cursor) === '{' &&
@ -55,7 +62,7 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
return true; return true;
} }
// inject whitespace for brace completion from selection: {{abc|}} -> {{ abc| }} // customization: inject whitespace for brace completion from selection: {{abc|}} -> {{ abc| }}
const [range] = view.state.selection.ranges; const [range] = view.state.selection.ranges;
@ -78,6 +85,12 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
const [_, bracketState] = closeBrackets() as readonly Extension[]; const [_, bracketState] = closeBrackets() as readonly Extension[];
/** /**
* CodeMirror plugin to handle double braces `{{ }}` for resolvables in n8n expressions. * CodeMirror plugin for (inline and modal) expression editor:
*
* - prevent token autoclosing during autocompletion (exception: `{`),
* - prevent square bracket autoclosing prior to `.json`
* - inject whitespace and braces for resolvables
*
* Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79).
*/ */
export const doubleBraceHandler = () => [inputHandler, bracketState]; export const expressionInputHandler = () => [handler, bracketState];

View file

@ -0,0 +1,33 @@
import { parserWithMetaData as n8nParser } from 'codemirror-lang-n8n-expression';
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import { javascriptLanguage } from '@codemirror/lang-javascript';
import { ifIn } from '@codemirror/autocomplete';
import { proxyCompletions } from './completions/proxy.completions';
import { rootCompletions } from './completions/root.completions';
import { luxonCompletions } from './completions/luxon.completions';
import { alphaCompletions } from './completions/alpha.completions';
const n8nParserWithNestedJsParser = n8nParser.configure({
wrap: parseMixed((node) => {
if (node.type.isTop) return null;
return node.name === 'Resolvable'
? { parser: javascriptLanguage.parser, overlay: (node) => node.type.name === 'Resolvable' }
: null;
}),
});
const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser });
export function n8nLang() {
const options = [alphaCompletions, rootCompletions, proxyCompletions, luxonCompletions].map(
(group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) }),
);
return new LanguageSupport(n8nLanguage, [
n8nLanguage.data.of({ closeBrackets: { brackets: ['{'] } }),
...options,
]);
}

View file

@ -1,20 +0,0 @@
import { parserWithMetaData as n8nParser } from 'codemirror-lang-n8n-expression';
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parseMixed } from '@lezer/common';
import { parser as jsParser } from '@lezer/javascript';
const parserWithNestedJsParser = n8nParser.configure({
wrap: parseMixed((node) => {
if (node.type.isTop) return null;
return node.name === 'Resolvable'
? { parser: jsParser, overlay: (node) => node.type.name === 'Resolvable' }
: null;
}),
});
const n8nLanguage = LRLanguage.define({ parser: parserWithNestedJsParser });
export function n8nLanguageSupport() {
return new LanguageSupport(n8nLanguage);
}

View file

@ -1,33 +0,0 @@
import { CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { syntaxTree } from '@codemirror/language';
/**
* Completions available inside the resolvable segment `{{ ... }}` of an n8n expression.
*
* Currently unused.
*/
export function resolvableCompletions(context: CompletionContext): CompletionResult | null {
const nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1);
if (nodeBefore.name !== 'Resolvable') return null;
const pattern = /(?<quotedString>('|")\w*('|"))\./;
const preCursor = context.matchBefore(pattern);
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const match = preCursor.text.match(pattern);
if (!match?.groups?.quotedString) return null;
const { quotedString } = match.groups;
return {
from: preCursor.from,
options: [
{ label: `${quotedString}.replace()`, info: 'Replace part of a string with another' },
{ label: `${quotedString}.slice()`, info: 'Copy part of a string' },
],
};
}

View file

@ -325,6 +325,153 @@ export class I18nClass {
}, },
}; };
} }
rootVars: Record<string, string | undefined> = {
$binary: this.baseText('codeNodeEditor.completer.binary'),
$execution: this.baseText('codeNodeEditor.completer.$execution'),
$input: this.baseText('codeNodeEditor.completer.$input'),
'$jmespath()': this.baseText('codeNodeEditor.completer.$jmespath'),
$json: this.baseText('codeNodeEditor.completer.json'),
$itemIndex: this.baseText('codeNodeEditor.completer.$itemIndex'),
$now: this.baseText('codeNodeEditor.completer.$now'),
$prevNode: this.baseText('codeNodeEditor.completer.$prevNode'),
$runIndex: this.baseText('codeNodeEditor.completer.$runIndex'),
$today: this.baseText('codeNodeEditor.completer.$today'),
$workflow: this.baseText('codeNodeEditor.completer.$workflow'),
};
proxyVars: Record<string, string | undefined> = {
'$input.all': this.baseText('codeNodeEditor.completer.$input.all'),
'$input.first': this.baseText('codeNodeEditor.completer.$input.first'),
'$input.item': this.baseText('codeNodeEditor.completer.$input.item'),
'$input.last': this.baseText('codeNodeEditor.completer.$input.last'),
'$().all': this.baseText('codeNodeEditor.completer.selector.all'),
'$().context': this.baseText('codeNodeEditor.completer.selector.context'),
'$().first': this.baseText('codeNodeEditor.completer.selector.first'),
'$().item': this.baseText('codeNodeEditor.completer.selector.item'),
'$().itemMatching': this.baseText('codeNodeEditor.completer.selector.itemMatching'),
'$().last': this.baseText('codeNodeEditor.completer.selector.last'),
'$().params': this.baseText('codeNodeEditor.completer.selector.params'),
'$prevNode.name': this.baseText('codeNodeEditor.completer.$prevNode.name'),
'$prevNode.outputIndex': this.baseText('codeNodeEditor.completer.$prevNode.outputIndex'),
'$prevNode.runIndex': this.baseText('codeNodeEditor.completer.$prevNode.runIndex'),
'$execution.id': this.baseText('codeNodeEditor.completer.$workflow.id'),
'$execution.mode': this.baseText('codeNodeEditor.completer.$execution.mode'),
'$execution.resumeUrl': this.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
'$workflow.active': this.baseText('codeNodeEditor.completer.$workflow.active'),
'$workflow.id': this.baseText('codeNodeEditor.completer.$workflow.id'),
'$workflow.name': this.baseText('codeNodeEditor.completer.$workflow.name'),
};
luxonInstance: Record<string, string | undefined> = {
// getters
isValid: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isValid'),
invalidReason: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.invalidReason'),
invalidExplanation: this.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.invalidExplanation',
),
locale: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.locale'),
numberingSystem: this.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.numberingSystem',
),
outputCalendar: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.outputCalendar'),
zone: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.zone'),
zoneName: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.zoneName'),
year: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.year'),
quarter: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.quarter'),
month: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.month'),
day: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.day'),
hour: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.hour'),
minute: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.minute'),
second: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.second'),
millisecond: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.millisecond'),
weekYear: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekYear'),
weekNumber: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekNumber'),
weekday: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekday'),
ordinal: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.ordinal'),
monthShort: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.monthShort'),
monthLong: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.monthLong'),
weekdayShort: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekdayShort'),
weekdayLong: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.weekdayLong'),
offset: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.offset'),
offsetNumber: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.offsetNumber'),
offsetNameShort: this.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.offsetNameShort',
),
offsetNameLong: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.offsetNameLong'),
isOffsetFixed: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isOffsetFixed'),
isInDST: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInDST'),
isInLeapYear: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.isInLeapYear'),
daysInMonth: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.daysInMonth'),
daysInYear: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.daysInYear'),
weeksInWeekYear: this.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.weeksInWeekYear',
),
// methods
toUTC: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUTC'),
toLocal: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocal'),
setZone: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.setZone'),
setLocale: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.setLocale'),
set: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.set'),
plus: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.plus'),
minus: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.minus'),
startOf: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.startOf'),
endOf: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.endOf'),
toFormat: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toFormat'),
toLocaleString: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocaleString'),
toLocaleParts: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toLocaleParts'),
toISO: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISO'),
toISODate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISODate'),
toISOWeekDate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISOWeekDate'),
toISOTime: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toISOTime'),
toRFC2822: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toRFC2822'),
toHTTP: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toHTTP'),
toSQLDate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQLDate'),
toSQLTime: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQLTime'),
toSQL: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSQL'),
toString: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toString'),
valueOf: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.valueOf'),
toMillis: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toMillis'),
toSeconds: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toSeconds'),
toUnixInteger: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toUnixInteger'),
toJSON: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJSON'),
toBSON: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toBSON'),
toObject: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toObject'),
toJsDate: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toJsDate'),
diff: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.diff'),
diffNow: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.diffNow'),
until: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.until'),
hasSame: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.hasSame'),
equals: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.equals'),
toRelative: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.toRelative'),
toRelativeCalendar: this.baseText(
'codeNodeEditor.completer.luxon.instanceMethods.toRelativeCalendar',
),
min: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.min'),
max: this.baseText('codeNodeEditor.completer.luxon.instanceMethods.max'),
};
luxonStatic: Record<string, string | undefined> = {
now: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.now'),
local: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.local'),
utc: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.utc'),
fromJSDate: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromJSDate'),
fromMillis: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromMillis'),
fromSeconds: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSeconds'),
fromObject: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromObject'),
fromISO: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromISO'),
fromRFC2822: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromRFC2822'),
fromHTTP: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromHTTP'),
fromFormat: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromFormat'),
fromSQL: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.fromSQL'),
invalid: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.invalid'),
isDateTime: this.baseText('codeNodeEditor.completer.luxon.dateTimeStaticMethods.isDateTime'),
};
} }
export const i18nInstance = new VueI18n({ export const i18nInstance = new VueI18n({

View file

@ -0,0 +1 @@
export type Word = { from: number; to: number; text: string };

View file

@ -255,7 +255,7 @@ export class Expression {
const returnValue = this.renderExpression(parameterValue, data); const returnValue = this.renderExpression(parameterValue, data);
if (typeof returnValue === 'function') { if (typeof returnValue === 'function') {
if (returnValue.name === '$') throw new Error('invalid syntax'); if (returnValue.name === '$') throw new Error('invalid syntax');
throw new Error(`${returnValue.name} is a function. Please add ()`); throw new Error('This is a function. Please add ()');
} else if (typeof returnValue === 'string') { } else if (typeof returnValue === 'string') {
return returnValue; return returnValue;
} else if (returnValue !== null && typeof returnValue === 'object') { } else if (returnValue !== null && typeof returnValue === 'object') {

View file

@ -130,7 +130,7 @@ export class WorkflowDataProxy {
return {}; // incoming connection has pinned data, so stub context object return {}; // incoming connection has pinned data, so stub context object
} }
if (!that.runExecutionData?.executionData) { if (!that.runExecutionData?.executionData && !that.runExecutionData?.resultData) {
throw new ExpressionError( throw new ExpressionError(
"The workflow hasn't been executed yet, so you can't reference any context data", "The workflow hasn't been executed yet, so you can't reference any context data",
{ {
@ -931,6 +931,18 @@ export class WorkflowDataProxy {
return new Proxy( return new Proxy(
{}, {},
{ {
ownKeys(target) {
return [
'pairedItem',
'itemMatching',
'item',
'first',
'last',
'all',
'context',
'params',
];
},
get(target, property, receiver) { get(target, property, receiver) {
if (['pairedItem', 'itemMatching', 'item'].includes(property as string)) { if (['pairedItem', 'itemMatching', 'item'].includes(property as string)) {
const pairedItemMethod = (itemIndex?: number) => { const pairedItemMethod = (itemIndex?: number) => {

View file

@ -509,9 +509,9 @@ importers:
packages/editor-ui: packages/editor-ui:
specifiers: specifiers:
'@codemirror/autocomplete': ^6.1.0 '@codemirror/autocomplete': ^6.4.0
'@codemirror/commands': ^6.1.0 '@codemirror/commands': ^6.1.0
'@codemirror/lang-javascript': ^6.0.2 '@codemirror/lang-javascript': ^6.1.2
'@codemirror/language': ^6.2.1 '@codemirror/language': ^6.2.1
'@codemirror/lint': ^6.0.0 '@codemirror/lint': ^6.0.0
'@codemirror/state': ^6.1.4 '@codemirror/state': ^6.1.4
@ -586,9 +586,9 @@ importers:
vue2-touch-events: ^3.2.1 vue2-touch-events: ^3.2.1
xss: ^1.0.10 xss: ^1.0.10
dependencies: dependencies:
'@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/commands': 6.1.2 '@codemirror/commands': 6.1.2
'@codemirror/lang-javascript': 6.1.0 '@codemirror/lang-javascript': 6.1.2
'@codemirror/language': 6.2.1 '@codemirror/language': 6.2.1
'@codemirror/lint': 6.0.0 '@codemirror/lint': 6.0.0
'@codemirror/state': 6.1.4 '@codemirror/state': 6.1.4
@ -2665,8 +2665,8 @@ packages:
minimist: 1.2.7 minimist: 1.2.7
dev: true dev: true
/@codemirror/autocomplete/6.3.0_wo7q3lvweq5evsu423o7qzum5i: /@codemirror/autocomplete/6.4.0_wo7q3lvweq5evsu423o7qzum5i:
resolution: {integrity: sha512-4jEvh3AjJZTDKazd10J6ZsCIqaYxDMCeua5ouQxY8hlFIml+nr7le0SgBhT3SIytFBmdzPK3AUhXGuW3T79nVg==} resolution: {integrity: sha512-HLF2PnZAm1s4kGs30EiqKMgD7XsYaQ0XJnMR0rofEWQ5t5D60SfqpDIkIh1ze5tiEbyUWm8+VJ6W1/erVvBMIA==}
peerDependencies: peerDependencies:
'@codemirror/language': ^6.0.0 '@codemirror/language': ^6.0.0
'@codemirror/state': ^6.0.0 '@codemirror/state': ^6.0.0
@ -2688,10 +2688,10 @@ packages:
'@lezer/common': 1.0.1 '@lezer/common': 1.0.1
dev: false dev: false
/@codemirror/lang-javascript/6.1.0: /@codemirror/lang-javascript/6.1.2:
resolution: {integrity: sha512-wAWEY1Wdis2cKDy9A5q/rUmzLHFbZgoupJBcGaeMMsDPi68Rm90NsmzAEODE5kW8mYdRKFhQ157WJghOZ3yYdg==} resolution: {integrity: sha512-OcwLfZXdQ1OHrLiIcKCn7MqZ7nx205CMKlhe+vL88pe2ymhT9+2P+QhwkYGxMICj8TDHyp8HFKVwpiisUT7iEQ==}
dependencies: dependencies:
'@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/language': 6.2.1 '@codemirror/language': 6.2.1
'@codemirror/lint': 6.0.0 '@codemirror/lint': 6.0.0
'@codemirror/state': 6.1.4 '@codemirror/state': 6.1.4
@ -9031,7 +9031,7 @@ packages:
/codemirror-lang-n8n-expression/0.1.0_zyklskjzaprvz25ee7sq7godcq: /codemirror-lang-n8n-expression/0.1.0_zyklskjzaprvz25ee7sq7godcq:
resolution: {integrity: sha512-20ss5p0koTu5bfivr1sBHYs7cpjWT2JhVB5gn7TX9WWPt+v/9p9tEcYSOyL/sm+OFuWh698Cgnmrba4efQnMCQ==} resolution: {integrity: sha512-20ss5p0koTu5bfivr1sBHYs7cpjWT2JhVB5gn7TX9WWPt+v/9p9tEcYSOyL/sm+OFuWh698Cgnmrba4efQnMCQ==}
dependencies: dependencies:
'@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/language': 6.2.1 '@codemirror/language': 6.2.1
'@lezer/highlight': 1.1.1 '@lezer/highlight': 1.1.1
'@lezer/lr': 1.2.3 '@lezer/lr': 1.2.3