mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
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:
parent
77031a2950
commit
f4140d011f
|
@ -27,9 +27,9 @@
|
|||
"test:dev": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.1.0",
|
||||
"@codemirror/autocomplete": "^6.4.0",
|
||||
"@codemirror/commands": "^6.1.0",
|
||||
"@codemirror/lang-javascript": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.1.2",
|
||||
"@codemirror/language": "^6.2.1",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.1.4",
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
|
||||
import { acceptCompletion, closeBrackets } from '@codemirror/autocomplete';
|
||||
import { acceptCompletion } from '@codemirror/autocomplete';
|
||||
import {
|
||||
history,
|
||||
indentWithTab,
|
||||
|
@ -16,11 +16,8 @@ import {
|
|||
toggleComment,
|
||||
} from '@codemirror/commands';
|
||||
import { lintGutter } from '@codemirror/lint';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
|
||||
import { customInputHandler } from './inputHandler';
|
||||
|
||||
const [_, bracketState] = closeBrackets() as readonly Extension[];
|
||||
import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler';
|
||||
|
||||
export const baseExtensions = [
|
||||
lineNumbers(),
|
||||
|
@ -29,7 +26,7 @@ export const baseExtensions = [
|
|||
history(),
|
||||
foldGutter(),
|
||||
lintGutter(),
|
||||
[customInputHandler, bracketState],
|
||||
codeInputHandler(),
|
||||
dropCursor(),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
|
|
|
@ -54,7 +54,7 @@ export const completerExtension = mixins(
|
|||
// luxon
|
||||
this.todayCompletions,
|
||||
this.nowCompletions,
|
||||
this.dateTimeCompltions,
|
||||
this.dateTimeCompletions,
|
||||
|
||||
// item index
|
||||
this.inputCompletions,
|
||||
|
@ -174,7 +174,7 @@ export const completerExtension = mixins(
|
|||
|
||||
if (value === '$now') return this.nowCompletions(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
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
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 { IDataObject, IPinData, IRunData } from 'n8n-workflow';
|
||||
import type { CodeNodeEditorMixin } from '../types';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useWorkflowsStore } from '@/stores/workflows';
|
||||
import { useNDVStore } from '@/stores/ndv';
|
||||
import { isAllowedInDotNotation } from '@/plugins/codemirror/completions/utils';
|
||||
|
||||
export const jsonFieldCompletions = (Vue as CodeNodeEditorMixin).extend({
|
||||
computed: {
|
||||
|
|
|
@ -76,7 +76,7 @@ export const luxonCompletions = (Vue as CodeNodeEditorMixin).extend({
|
|||
/**
|
||||
* 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 preCursor = context.matchBefore(pattern);
|
||||
|
|
|
@ -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;
|
||||
});
|
|
@ -29,12 +29,6 @@ export function walk<T extends RangeNode>(
|
|||
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) =>
|
||||
str
|
||||
.replace('$', '\\$')
|
||||
|
|
|
@ -10,13 +10,14 @@ import { history } from '@codemirror/commands';
|
|||
|
||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { expressionManager } from '@/mixins/expressionManager';
|
||||
import { doubleBraceHandler } from '@/plugins/codemirror/doubleBraceHandler';
|
||||
import { n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport';
|
||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||
import { n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import { inputTheme } from './theme';
|
||||
|
||||
import type { IVariableItemSelected } from '@/Interface';
|
||||
import { forceParse } from '@/utils/forceParse';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
|
||||
export default mixins(expressionManager, workflowHelpers).extend({
|
||||
name: 'ExpressionEditorModalInput',
|
||||
|
@ -36,9 +37,10 @@ export default mixins(expressionManager, workflowHelpers).extend({
|
|||
mounted() {
|
||||
const extensions = [
|
||||
inputTheme(),
|
||||
n8nLanguageSupport(),
|
||||
autocompletion(),
|
||||
n8nLang(),
|
||||
history(),
|
||||
doubleBraceHandler(),
|
||||
expressionInputHandler(),
|
||||
EditorView.lineWrapping,
|
||||
EditorState.readOnly.of(this.isReadOnly),
|
||||
EditorView.domEventHandlers({ scroll: forceParse }),
|
||||
|
|
|
@ -13,9 +13,10 @@ import { useNDVStore } from '@/stores/ndv';
|
|||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { expressionManager } from '@/mixins/expressionManager';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import { n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport';
|
||||
import { doubleBraceHandler } from '@/plugins/codemirror/doubleBraceHandler';
|
||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||
import { inputTheme } from './theme';
|
||||
import { autocompletion, ifIn } from '@codemirror/autocomplete';
|
||||
import { n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||
|
||||
export default mixins(expressionManager, workflowHelpers).extend({
|
||||
name: 'InlineExpressionEditorInput',
|
||||
|
@ -39,35 +40,19 @@ export default mixins(expressionManager, workflowHelpers).extend({
|
|||
},
|
||||
watch: {
|
||||
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: {
|
||||
from: 0,
|
||||
to: this.editor?.state.doc.length,
|
||||
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() {
|
||||
this.editor?.dispatch({
|
||||
|
@ -92,9 +77,10 @@ export default mixins(expressionManager, workflowHelpers).extend({
|
|||
mounted() {
|
||||
const extensions = [
|
||||
inputTheme({ isSingleLine: this.isSingleLine }),
|
||||
n8nLanguageSupport(),
|
||||
autocompletion(),
|
||||
n8nLang(),
|
||||
history(),
|
||||
doubleBraceHandler(),
|
||||
expressionInputHandler(),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.editable.of(!this.isReadOnly),
|
||||
EditorView.domEventHandlers({
|
||||
|
|
|
@ -69,6 +69,357 @@ import { ICredentialsResponse } from '@/Interface';
|
|||
let cachedWorkflowKey: string | null = '';
|
||||
let cachedWorkflow: Workflow | null = null;
|
||||
|
||||
export function 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 = useNDVStore().activeNode;
|
||||
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 }))],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
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[],
|
||||
currentNode: string,
|
||||
inputName: string,
|
||||
runIndex: number,
|
||||
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 = {
|
||||
node: {},
|
||||
data: {},
|
||||
source: null,
|
||||
} as IExecuteData;
|
||||
|
||||
if (parentNode.length) {
|
||||
// Add the input data to be able to also resolve the short expression format
|
||||
// which does not use the node name
|
||||
const parentNodeName = parentNode[0];
|
||||
|
||||
const parentPinData = useWorkflowsStore().getPinData![parentNodeName];
|
||||
|
||||
// populate `executeData` from `pinData`
|
||||
|
||||
if (parentPinData) {
|
||||
executeData.data = { main: [parentPinData] };
|
||||
executeData.source = { main: [{ previousNode: parentNodeName }] };
|
||||
|
||||
return executeData;
|
||||
}
|
||||
|
||||
// populate `executeData` from `runData`
|
||||
|
||||
const workflowRunData = useWorkflowsStore().getWorkflowRunData;
|
||||
if (workflowRunData === null) {
|
||||
return executeData;
|
||||
}
|
||||
|
||||
if (
|
||||
!workflowRunData[parentNodeName] ||
|
||||
workflowRunData[parentNodeName].length <= runIndex ||
|
||||
!workflowRunData[parentNodeName][runIndex] ||
|
||||
!workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') ||
|
||||
workflowRunData[parentNodeName][runIndex].data === undefined ||
|
||||
!workflowRunData[parentNodeName][runIndex].data!.hasOwnProperty(inputName)
|
||||
) {
|
||||
executeData.data = {};
|
||||
} else {
|
||||
executeData.data = workflowRunData[parentNodeName][runIndex].data!;
|
||||
if (workflowRunData[currentNode] && workflowRunData[currentNode][runIndex]) {
|
||||
executeData.source = {
|
||||
[inputName]: workflowRunData[currentNode][runIndex].source!,
|
||||
};
|
||||
} else {
|
||||
// The current node did not get executed in UI yet so build data manually
|
||||
executeData.source = {
|
||||
[inputName]: [
|
||||
{
|
||||
previousNode: parentNodeName,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return executeData;
|
||||
}
|
||||
|
||||
export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showMessage).extend({
|
||||
computed: {
|
||||
...mapStores(
|
||||
|
@ -86,154 +437,13 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
executeData(
|
||||
parentNode: string[],
|
||||
currentNode: string,
|
||||
inputName: string,
|
||||
runIndex: number,
|
||||
): IExecuteData {
|
||||
const executeData = {
|
||||
node: {},
|
||||
data: {},
|
||||
source: null,
|
||||
} as IExecuteData;
|
||||
|
||||
if (parentNode.length) {
|
||||
// Add the input data to be able to also resolve the short expression format
|
||||
// which does not use the node name
|
||||
const parentNodeName = parentNode[0];
|
||||
|
||||
const parentPinData = this.workflowsStore.getPinData![parentNodeName];
|
||||
|
||||
// populate `executeData` from `pinData`
|
||||
|
||||
if (parentPinData) {
|
||||
executeData.data = { main: [parentPinData] };
|
||||
executeData.source = { main: [{ previousNode: parentNodeName }] };
|
||||
|
||||
return executeData;
|
||||
}
|
||||
|
||||
// populate `executeData` from `runData`
|
||||
|
||||
const workflowRunData = this.workflowsStore.getWorkflowRunData;
|
||||
if (workflowRunData === null) {
|
||||
return executeData;
|
||||
}
|
||||
|
||||
if (
|
||||
!workflowRunData[parentNodeName] ||
|
||||
workflowRunData[parentNodeName].length <= runIndex ||
|
||||
!workflowRunData[parentNodeName][runIndex] ||
|
||||
!workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') ||
|
||||
workflowRunData[parentNodeName][runIndex].data === undefined ||
|
||||
!workflowRunData[parentNodeName][runIndex].data!.hasOwnProperty(inputName)
|
||||
) {
|
||||
executeData.data = {};
|
||||
} else {
|
||||
executeData.data = workflowRunData[parentNodeName][runIndex].data!;
|
||||
if (workflowRunData[currentNode] && workflowRunData[currentNode][runIndex]) {
|
||||
executeData.source = {
|
||||
[inputName]: workflowRunData[currentNode][runIndex].source!,
|
||||
};
|
||||
} else {
|
||||
// The current node did not get executed in UI yet so build data manually
|
||||
executeData.source = {
|
||||
[inputName]: [
|
||||
{
|
||||
previousNode: parentNodeName,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return executeData;
|
||||
},
|
||||
// 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;
|
||||
},
|
||||
resolveParameter,
|
||||
getCurrentWorkflow,
|
||||
getNodes,
|
||||
getWorkflow,
|
||||
getNodeTypes,
|
||||
connectionInputData,
|
||||
executeData,
|
||||
|
||||
// Returns data about nodeTypes which have a "maxNodes" limit set.
|
||||
// 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;
|
||||
},
|
||||
|
||||
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.
|
||||
getWorkflowDataToSave(): Promise<IWorkflowData> {
|
||||
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);
|
||||
},
|
||||
|
||||
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(
|
||||
expression: string,
|
||||
siblingParameters: INodeParameters = {},
|
||||
|
@ -731,13 +740,13 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
|
|||
__xxxxxxx__: expression,
|
||||
...siblingParameters,
|
||||
};
|
||||
const returnData: IDataObject | null = this.resolveParameter(parameters, opts);
|
||||
const returnData: IDataObject | null = resolveParameter(parameters, opts);
|
||||
if (!returnData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof returnData['__xxxxxxx__'] === 'object') {
|
||||
const workflow = this.getCurrentWorkflow();
|
||||
const workflow = getCurrentWorkflow();
|
||||
return workflow.expression.convertObjectValueToString(returnData['__xxxxxxx__'] as object);
|
||||
}
|
||||
return returnData['__xxxxxxx__'];
|
||||
|
@ -980,7 +989,7 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
|
|||
this.uiStore.stateIsDirty = false;
|
||||
this.$externalHooks().run('workflow.afterUpdate', { workflowData });
|
||||
|
||||
this.getCurrentWorkflow(true); // refresh cache
|
||||
getCurrentWorkflow(true); // refresh cache
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.uiStore.removeActiveAction('workflowSaving');
|
||||
|
|
|
@ -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();
|
||||
});
|
|
@ -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);
|
||||
});
|
|
@ -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();
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
);
|
|
@ -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();
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
};
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
};
|
|
@ -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];
|
|
@ -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 { 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;
|
||||
|
||||
// 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) ||
|
||||
|
@ -22,14 +33,10 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
|||
|
||||
view.dispatch(transaction);
|
||||
|
||||
/**
|
||||
* Customizations to inject whitespace and braces for setup and completion
|
||||
*/
|
||||
// customization: inject whitespace and second brace for brace completion: {| } -> {{ | }}
|
||||
|
||||
const cursor = view.state.selection.main.head;
|
||||
|
||||
// inject whitespace and second brace for brace completion: {| } -> {{ | }}
|
||||
|
||||
const isBraceCompletion =
|
||||
view.state.sliceDoc(cursor - 2, cursor) === '{{' &&
|
||||
view.state.sliceDoc(cursor, cursor + 1) === '}';
|
||||
|
@ -43,7 +50,7 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
|||
return true;
|
||||
}
|
||||
|
||||
// inject whitespace for brace setup: empty -> {| }
|
||||
// customization: inject whitespace for brace setup: empty -> {| }
|
||||
|
||||
const isBraceSetup =
|
||||
view.state.sliceDoc(cursor - 1, cursor) === '{' &&
|
||||
|
@ -55,7 +62,7 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
|||
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;
|
||||
|
||||
|
@ -78,6 +85,12 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
|
|||
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];
|
33
packages/editor-ui/src/plugins/codemirror/n8nLang.ts
Normal file
33
packages/editor-ui/src/plugins/codemirror/n8nLang.ts
Normal 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,
|
||||
]);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
}
|
|
@ -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({
|
||||
|
|
1
packages/editor-ui/src/types/completions.ts
Normal file
1
packages/editor-ui/src/types/completions.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export type Word = { from: number; to: number; text: string };
|
|
@ -255,7 +255,7 @@ export class Expression {
|
|||
const returnValue = this.renderExpression(parameterValue, data);
|
||||
if (typeof returnValue === 'function') {
|
||||
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') {
|
||||
return returnValue;
|
||||
} else if (returnValue !== null && typeof returnValue === 'object') {
|
||||
|
|
|
@ -130,7 +130,7 @@ export class WorkflowDataProxy {
|
|||
return {}; // incoming connection has pinned data, so stub context object
|
||||
}
|
||||
|
||||
if (!that.runExecutionData?.executionData) {
|
||||
if (!that.runExecutionData?.executionData && !that.runExecutionData?.resultData) {
|
||||
throw new ExpressionError(
|
||||
"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(
|
||||
{},
|
||||
{
|
||||
ownKeys(target) {
|
||||
return [
|
||||
'pairedItem',
|
||||
'itemMatching',
|
||||
'item',
|
||||
'first',
|
||||
'last',
|
||||
'all',
|
||||
'context',
|
||||
'params',
|
||||
];
|
||||
},
|
||||
get(target, property, receiver) {
|
||||
if (['pairedItem', 'itemMatching', 'item'].includes(property as string)) {
|
||||
const pairedItemMethod = (itemIndex?: number) => {
|
||||
|
|
|
@ -509,9 +509,9 @@ importers:
|
|||
|
||||
packages/editor-ui:
|
||||
specifiers:
|
||||
'@codemirror/autocomplete': ^6.1.0
|
||||
'@codemirror/autocomplete': ^6.4.0
|
||||
'@codemirror/commands': ^6.1.0
|
||||
'@codemirror/lang-javascript': ^6.0.2
|
||||
'@codemirror/lang-javascript': ^6.1.2
|
||||
'@codemirror/language': ^6.2.1
|
||||
'@codemirror/lint': ^6.0.0
|
||||
'@codemirror/state': ^6.1.4
|
||||
|
@ -586,9 +586,9 @@ importers:
|
|||
vue2-touch-events: ^3.2.1
|
||||
xss: ^1.0.10
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i
|
||||
'@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
|
||||
'@codemirror/commands': 6.1.2
|
||||
'@codemirror/lang-javascript': 6.1.0
|
||||
'@codemirror/lang-javascript': 6.1.2
|
||||
'@codemirror/language': 6.2.1
|
||||
'@codemirror/lint': 6.0.0
|
||||
'@codemirror/state': 6.1.4
|
||||
|
@ -2665,8 +2665,8 @@ packages:
|
|||
minimist: 1.2.7
|
||||
dev: true
|
||||
|
||||
/@codemirror/autocomplete/6.3.0_wo7q3lvweq5evsu423o7qzum5i:
|
||||
resolution: {integrity: sha512-4jEvh3AjJZTDKazd10J6ZsCIqaYxDMCeua5ouQxY8hlFIml+nr7le0SgBhT3SIytFBmdzPK3AUhXGuW3T79nVg==}
|
||||
/@codemirror/autocomplete/6.4.0_wo7q3lvweq5evsu423o7qzum5i:
|
||||
resolution: {integrity: sha512-HLF2PnZAm1s4kGs30EiqKMgD7XsYaQ0XJnMR0rofEWQ5t5D60SfqpDIkIh1ze5tiEbyUWm8+VJ6W1/erVvBMIA==}
|
||||
peerDependencies:
|
||||
'@codemirror/language': ^6.0.0
|
||||
'@codemirror/state': ^6.0.0
|
||||
|
@ -2688,10 +2688,10 @@ packages:
|
|||
'@lezer/common': 1.0.1
|
||||
dev: false
|
||||
|
||||
/@codemirror/lang-javascript/6.1.0:
|
||||
resolution: {integrity: sha512-wAWEY1Wdis2cKDy9A5q/rUmzLHFbZgoupJBcGaeMMsDPi68Rm90NsmzAEODE5kW8mYdRKFhQ157WJghOZ3yYdg==}
|
||||
/@codemirror/lang-javascript/6.1.2:
|
||||
resolution: {integrity: sha512-OcwLfZXdQ1OHrLiIcKCn7MqZ7nx205CMKlhe+vL88pe2ymhT9+2P+QhwkYGxMICj8TDHyp8HFKVwpiisUT7iEQ==}
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i
|
||||
'@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
|
||||
'@codemirror/language': 6.2.1
|
||||
'@codemirror/lint': 6.0.0
|
||||
'@codemirror/state': 6.1.4
|
||||
|
@ -9031,7 +9031,7 @@ packages:
|
|||
/codemirror-lang-n8n-expression/0.1.0_zyklskjzaprvz25ee7sq7godcq:
|
||||
resolution: {integrity: sha512-20ss5p0koTu5bfivr1sBHYs7cpjWT2JhVB5gn7TX9WWPt+v/9p9tEcYSOyL/sm+OFuWh698Cgnmrba4efQnMCQ==}
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i
|
||||
'@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
|
||||
'@codemirror/language': 6.2.1
|
||||
'@lezer/highlight': 1.1.1
|
||||
'@lezer/lr': 1.2.3
|
||||
|
|
Loading…
Reference in a new issue