mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -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"
|
"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",
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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[];
|
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('$', '\\$')
|
||||||
|
|
|
@ -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 }),
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -69,6 +69,357 @@ import { ICredentialsResponse } from '@/Interface';
|
||||||
let cachedWorkflowKey: string | null = '';
|
let cachedWorkflowKey: string | null = '';
|
||||||
let cachedWorkflow: Workflow | null = 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({
|
export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showMessage).extend({
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(
|
...mapStores(
|
||||||
|
@ -86,154 +437,13 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
executeData(
|
resolveParameter,
|
||||||
parentNode: string[],
|
getCurrentWorkflow,
|
||||||
currentNode: string,
|
getNodes,
|
||||||
inputName: string,
|
getWorkflow,
|
||||||
runIndex: number,
|
getNodeTypes,
|
||||||
): IExecuteData {
|
connectionInputData,
|
||||||
const executeData = {
|
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;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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');
|
||||||
|
|
|
@ -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 { 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];
|
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({
|
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);
|
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') {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue