From a7d18ca7b419868522b7c53a2b60a6c164b5bbe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 3 Jan 2023 12:48:46 +0100 Subject: [PATCH 01/12] :zap: Introduce proxy completions to expressions --- packages/editor-ui/package.json | 4 +- .../CodeNodeEditor/baseExtensions.ts | 9 +- .../components/CodeNodeEditor/completer.ts | 4 +- .../completions/luxon.completions.ts | 2 +- .../components/CodeNodeEditor/inputHandler.ts | 42 - .../src/components/CodeNodeEditor/utils.ts | 1 + .../ExpressionEditorModalInput.vue | 11 +- .../InlineExpressionEditorInput.vue | 48 +- .../editor-ui/src/mixins/workflowHelpers.ts | 721 +++++++++--------- .../completions/luxon.completions.ts | 87 +++ .../completions/proxy.completions.ts | 109 +++ .../completions/root.completions.ts | 60 ++ .../plugins/codemirror/completions/utils.ts | 31 + .../inputHandlers/code.inputHandler.ts | 49 ++ .../expression.inputHandler.ts} | 35 +- .../plugins/codemirror/n8nLanguageSupport.ts | 31 +- .../codemirror/resolvable.completions.ts | 33 - packages/editor-ui/src/plugins/i18n/index.ts | 147 ++++ packages/editor-ui/src/types/completions.ts | 1 + packages/workflow/src/Expression.ts | 2 +- packages/workflow/src/WorkflowDataProxy.ts | 14 +- pnpm-lock.yaml | 20 +- 22 files changed, 967 insertions(+), 494 deletions(-) delete mode 100644 packages/editor-ui/src/components/CodeNodeEditor/inputHandler.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/utils.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/inputHandlers/code.inputHandler.ts rename packages/editor-ui/src/plugins/codemirror/{doubleBraceHandler.ts => inputHandlers/expression.inputHandler.ts} (56%) delete mode 100644 packages/editor-ui/src/plugins/codemirror/resolvable.completions.ts create mode 100644 packages/editor-ui/src/types/completions.ts diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 7faf5695e2c9f..2bc5188715d66 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -27,9 +27,9 @@ "test:dev": "vitest" }, "dependencies": { - "@codemirror/autocomplete": "^6.1.0", + "@codemirror/autocomplete": "^6.4.0", "@codemirror/commands": "^6.1.0", - "@codemirror/lang-javascript": "^6.0.2", + "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.2.1", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.1.4", diff --git a/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts b/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts index 5f8f2c029bec2..a2abdb1240577 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts @@ -8,7 +8,7 @@ import { lineNumbers, } from '@codemirror/view'; import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'; -import { acceptCompletion, closeBrackets } from '@codemirror/autocomplete'; +import { acceptCompletion } from '@codemirror/autocomplete'; import { history, indentWithTab, @@ -16,11 +16,8 @@ import { toggleComment, } from '@codemirror/commands'; import { lintGutter } from '@codemirror/lint'; -import type { Extension } from '@codemirror/state'; -import { customInputHandler } from './inputHandler'; - -const [_, bracketState] = closeBrackets() as readonly Extension[]; +import { codeInputHandler } from '@/plugins/codemirror/inputHandlers/code.inputHandler'; export const baseExtensions = [ lineNumbers(), @@ -29,7 +26,7 @@ export const baseExtensions = [ history(), foldGutter(), lintGutter(), - [customInputHandler, bracketState], + codeInputHandler(), dropCursor(), indentOnInput(), bracketMatching(), diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completer.ts b/packages/editor-ui/src/components/CodeNodeEditor/completer.ts index 85355cfca13be..0d4f03c9c7df8 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completer.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completer.ts @@ -54,7 +54,7 @@ export const completerExtension = mixins( // luxon this.todayCompletions, this.nowCompletions, - this.dateTimeCompltions, + this.dateTimeCompletions, // item index this.inputCompletions, @@ -174,7 +174,7 @@ export const completerExtension = mixins( if (value === '$now') return this.nowCompletions(context, variable); if (value === '$today') return this.todayCompletions(context, variable); - if (value === 'DateTime') return this.dateTimeCompltions(context, variable); + if (value === 'DateTime') return this.dateTimeCompletions(context, variable); // item index diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts index fe130e0647095..20c4005a785f4 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/luxon.completions.ts @@ -76,7 +76,7 @@ export const luxonCompletions = (Vue as CodeNodeEditorMixin).extend({ /** * Complete `DateTime` with luxon `DateTime` static methods. */ - dateTimeCompltions(context: CompletionContext, matcher = 'DateTime'): CompletionResult | null { + dateTimeCompletions(context: CompletionContext, matcher = 'DateTime'): CompletionResult | null { const pattern = new RegExp(`${escape(matcher)}\..*`); const preCursor = context.matchBefore(pattern); diff --git a/packages/editor-ui/src/components/CodeNodeEditor/inputHandler.ts b/packages/editor-ui/src/components/CodeNodeEditor/inputHandler.ts deleted file mode 100644 index d3776082ba965..0000000000000 --- a/packages/editor-ui/src/components/CodeNodeEditor/inputHandler.ts +++ /dev/null @@ -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; -}); diff --git a/packages/editor-ui/src/components/CodeNodeEditor/utils.ts b/packages/editor-ui/src/components/CodeNodeEditor/utils.ts index ed4f902219678..a252a46a85cf9 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/utils.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/utils.ts @@ -29,6 +29,7 @@ export function walk( return found as T[]; } +// @TODO: Dup - remove export const isAllowedInDotNotation = (str: string) => { const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g; diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index 708665446a1da..e81e4e4977557 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -10,13 +10,14 @@ import { history } from '@codemirror/commands'; import { workflowHelpers } from '@/mixins/workflowHelpers'; import { expressionManager } from '@/mixins/expressionManager'; -import { doubleBraceHandler } from '@/plugins/codemirror/doubleBraceHandler'; -import { n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport'; +import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; +import { n8nLang, n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport'; import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { inputTheme } from './theme'; import type { IVariableItemSelected } from '@/Interface'; import { forceParse } from '@/utils/forceParse'; +import { autocompletion } from '@codemirror/autocomplete'; export default mixins(expressionManager, workflowHelpers).extend({ name: 'ExpressionEditorModalInput', @@ -36,9 +37,11 @@ export default mixins(expressionManager, workflowHelpers).extend({ mounted() { const extensions = [ inputTheme(), - n8nLanguageSupport(), + // n8nLanguageSupport(), + autocompletion(), + n8nLang(), history(), - doubleBraceHandler(), + expressionInputHandler(), EditorView.lineWrapping, EditorState.readOnly.of(this.isReadOnly), EditorView.domEventHandlers({ scroll: forceParse }), diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index 0c24d16bbadff..8d9cdf1107fa8 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -13,9 +13,11 @@ import { useNDVStore } from '@/stores/ndv'; import { workflowHelpers } from '@/mixins/workflowHelpers'; import { expressionManager } from '@/mixins/expressionManager'; import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; -import { n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport'; -import { doubleBraceHandler } from '@/plugins/codemirror/doubleBraceHandler'; +// import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; +import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { inputTheme } from './theme'; +import { autocompletion, ifIn } from '@codemirror/autocomplete'; +import { n8nLang } from '@/plugins/codemirror/n8nLanguageSupport'; export default mixins(expressionManager, workflowHelpers).extend({ name: 'InlineExpressionEditorInput', @@ -39,35 +41,19 @@ export default mixins(expressionManager, workflowHelpers).extend({ }, watch: { value(newValue) { - const payload: Record = { + const isInternalChange = newValue === this.editor?.state.doc.toString(); + + if (isInternalChange) return; + + // manual update on external change, e.g. from expression modal or mapping drop + + this.editor?.dispatch({ changes: { from: 0, to: this.editor?.state.doc.length, insert: newValue, }, - selection: { anchor: this.cursorPosition, head: this.cursorPosition }, - }; - - /** - * If completion from selection, preserve selection. - */ - if (this.editor) { - const [range] = this.editor.state.selection.ranges; - - const isBraceAutoinsertion = - this.editor.state.sliceDoc(range.from - 1, range.from) === '{' && - this.editor.state.sliceDoc(range.to, range.to + 1) === '}'; - - if (isBraceAutoinsertion) { - payload.selection = { anchor: range.from, head: range.to }; - } - } - - try { - this.editor?.dispatch(payload); - } catch (_) { - // ignore out-of-range selection error on drop - } + }); }, ndvInputData() { this.editor?.dispatch({ @@ -92,9 +78,15 @@ export default mixins(expressionManager, workflowHelpers).extend({ mounted() { const extensions = [ inputTheme({ isSingleLine: this.isSingleLine }), - n8nLanguageSupport(), + autocompletion(), + n8nLang(), + // n8nLanguageSupport(), + // javascript(), + // javascriptLanguage.data.of({ + // autocomplete: myCompletions, + // }), history(), - doubleBraceHandler(), + expressionInputHandler(), EditorView.lineWrapping, EditorView.editable.of(!this.isReadOnly), EditorView.domEventHandlers({ diff --git a/packages/editor-ui/src/mixins/workflowHelpers.ts b/packages/editor-ui/src/mixins/workflowHelpers.ts index e4867e4f7c5df..4fbc64ae63d34 100644 --- a/packages/editor-ui/src/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/mixins/workflowHelpers.ts @@ -74,6 +74,357 @@ import { ICredentialsResponse } from '@/Interface'; let cachedWorkflowKey: string | null = ''; let cachedWorkflow: Workflow | null = null; +export function resolveParameter( + parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], + opts: { + targetItem?: TargetItem; + inputNodeName?: string; + inputRunIndex?: number; + inputBranchIndex?: number; + } = {}, +): IDataObject | null { + let itemIndex = opts?.targetItem?.itemIndex || 0; + + const inputName = 'main'; + const activeNode = useNDVStore().activeNode; + const workflow = getCurrentWorkflow(); + const workflowRunData = useWorkflowsStore().getWorkflowRunData; + let parentNode = workflow.getParentNodes(activeNode!.name, inputName, 1); + const executionData = useWorkflowsStore().getWorkflowExecution; + + if (opts?.inputNodeName && !parentNode.includes(opts.inputNodeName)) { + return null; + } + + let runIndexParent = opts?.inputRunIndex ?? 0; + const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]); + if (opts.targetItem && opts?.targetItem?.nodeName === activeNode!.name && executionData) { + const sourceItems = getSourceItems(executionData, opts.targetItem); + if (!sourceItems.length) { + return null; + } + parentNode = [sourceItems[0].nodeName]; + runIndexParent = sourceItems[0].runIndex; + itemIndex = sourceItems[0].itemIndex; + if (nodeConnection) { + nodeConnection.sourceIndex = sourceItems[0].outputIndex; + } + } else { + parentNode = opts.inputNodeName ? [opts.inputNodeName] : parentNode; + if (nodeConnection) { + nodeConnection.sourceIndex = opts.inputBranchIndex ?? nodeConnection.sourceIndex; + } + + if (opts?.inputRunIndex === undefined && workflowRunData !== null && parentNode.length) { + const firstParentWithWorkflowRunData = parentNode.find( + (parentNodeName) => workflowRunData[parentNodeName], + ); + if (firstParentWithWorkflowRunData) { + runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1; + } + } + } + + let _connectionInputData = connectionInputData( + parentNode, + activeNode!.name, + inputName, + runIndexParent, + nodeConnection, + ); + + let runExecutionData: IRunExecutionData; + if (executionData === null || !executionData.data) { + runExecutionData = { + resultData: { + runData: {}, + }, + }; + } else { + runExecutionData = executionData.data; + } + + parentNode.forEach((parentNodeName) => { + const pinData: IPinData[string] | undefined = + useWorkflowsStore().pinDataByNodeName(parentNodeName); + + if (pinData) { + runExecutionData = { + ...runExecutionData, + resultData: { + ...runExecutionData.resultData, + runData: { + ...runExecutionData.resultData.runData, + [parentNodeName]: [ + { + startTime: new Date().valueOf(), + executionTime: 0, + source: [], + data: { + main: [pinData.map((data) => ({ json: data }))], + }, + }, + ], + }, + }, + }; + } + }); + + if (_connectionInputData === null) { + _connectionInputData = []; + } + + const additionalKeys: IWorkflowDataProxyAdditionalKeys = { + $execution: { + id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, + mode: 'test', + resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, + }, + + // deprecated + $executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, + $resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, + }; + + let runIndexCurrent = opts?.targetItem?.runIndex ?? 0; + if ( + opts?.targetItem === undefined && + workflowRunData !== null && + workflowRunData[activeNode!.name] + ) { + runIndexCurrent = workflowRunData[activeNode!.name].length - 1; + } + const _executeData = executeData(parentNode, activeNode!.name, inputName, runIndexCurrent); + + return workflow.expression.getParameterValue( + parameter, + runExecutionData, + runIndexCurrent, + itemIndex, + activeNode!.name, + _connectionInputData, + 'manual', + useRootStore().timezone, + additionalKeys, + _executeData, + false, + ) as IDataObject; +} + +function getCurrentWorkflow(copyData?: boolean): Workflow { + const nodes = getNodes(); + const connections = useWorkflowsStore().allConnections; + const cacheKey = JSON.stringify({ nodes, connections }); + if (!copyData && cachedWorkflow && cacheKey === cachedWorkflowKey) { + return cachedWorkflow; + } + cachedWorkflowKey = cacheKey; + + return getWorkflow(nodes, connections, copyData); +} + +// Returns a shallow copy of the nodes which means that all the data on the lower +// levels still only gets referenced but the top level object is a different one. +// This has the advantage that it is very fast and does not cause problems with vuex +// when the workflow replaces the node-parameters. +function getNodes(): INodeUi[] { + const nodes = useWorkflowsStore().allNodes; + const returnNodes: INodeUi[] = []; + + for (const node of nodes) { + returnNodes.push(Object.assign({}, node)); + } + + return returnNodes; +} + +// Returns a workflow instance. +function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow { + const nodeTypes = getNodeTypes(); + let workflowId: string | undefined = useWorkflowsStore().workflowId; + if (workflowId && workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) { + workflowId = undefined; + } + + const workflowName = useWorkflowsStore().workflowName; + + cachedWorkflow = new Workflow({ + id: workflowId, + name: workflowName, + nodes: copyData ? deepCopy(nodes) : nodes, + connections: copyData ? deepCopy(connections) : connections, + active: false, + nodeTypes, + settings: useWorkflowsStore().workflowSettings, + // @ts-ignore + pinData: useWorkflowsStore().pinData, + }); + + return cachedWorkflow; +} + +function getNodeTypes(): INodeTypes { + const nodeTypes: INodeTypes = { + nodeTypes: {}, + init: async (nodeTypes?: INodeTypeData): Promise => {}, + // @ts-ignore + getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => { + const nodeTypeDescription = useNodeTypesStore().getNodeType(nodeType, version); + + if (nodeTypeDescription === null) { + return undefined; + } + + return { + description: nodeTypeDescription, + // As we do not have the trigger/poll functions available in the frontend + // we use the information available to figure out what are trigger nodes + // @ts-ignore + trigger: + (![ERROR_TRIGGER_NODE_TYPE, START_NODE_TYPE].includes(nodeType) && + nodeTypeDescription.inputs.length === 0 && + !nodeTypeDescription.webhooks) || + undefined, + }; + }, + }; + + return nodeTypes; +} + +// Returns connectionInputData to be able to execute an expression. +function connectionInputData( + parentNode: string[], + currentNode: string, + inputName: string, + runIndex: number, + nodeConnection: INodeConnection = { sourceIndex: 0, destinationIndex: 0 }, +): INodeExecutionData[] | null { + let connectionInputData: INodeExecutionData[] | null = null; + const _executeData = executeData(parentNode, currentNode, inputName, runIndex); + if (parentNode.length) { + if ( + !Object.keys(_executeData.data).length || + _executeData.data[inputName].length <= nodeConnection.sourceIndex + ) { + connectionInputData = []; + } else { + connectionInputData = _executeData.data![inputName][nodeConnection.sourceIndex]; + + if (connectionInputData !== null) { + // Update the pairedItem information on items + connectionInputData = connectionInputData.map((item, itemIndex) => { + return { + ...item, + pairedItem: { + item: itemIndex, + input: nodeConnection.destinationIndex, + }, + }; + }); + } + } + } + + const parentPinData = parentNode.reduce((acc: INodeExecutionData[], parentNodeName, index) => { + const pinData = useWorkflowsStore().pinDataByNodeName(parentNodeName); + + if (pinData) { + acc.push({ + json: pinData[0], + pairedItem: { + item: index, + input: 1, + }, + }); + } + + return acc; + }, []); + + if (parentPinData.length > 0) { + if (connectionInputData && connectionInputData.length > 0) { + parentPinData.forEach((parentPinDataEntry) => { + connectionInputData![0].json = { + ...connectionInputData![0].json, + ...parentPinDataEntry.json, + }; + }); + } else { + connectionInputData = parentPinData; + } + } + + return connectionInputData; +} + +function executeData( + parentNode: string[], + currentNode: string, + inputName: string, + runIndex: number, +): IExecuteData { + const executeData = { + node: {}, + data: {}, + source: null, + } as IExecuteData; + + if (parentNode.length) { + // Add the input data to be able to also resolve the short expression format + // which does not use the node name + const parentNodeName = parentNode[0]; + + const parentPinData = useWorkflowsStore().getPinData![parentNodeName]; + + // populate `executeData` from `pinData` + + if (parentPinData) { + executeData.data = { main: [parentPinData] }; + executeData.source = { main: [{ previousNode: parentNodeName }] }; + + return executeData; + } + + // populate `executeData` from `runData` + + const workflowRunData = useWorkflowsStore().getWorkflowRunData; + if (workflowRunData === null) { + return executeData; + } + + if ( + !workflowRunData[parentNodeName] || + workflowRunData[parentNodeName].length <= runIndex || + !workflowRunData[parentNodeName][runIndex] || + !workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') || + workflowRunData[parentNodeName][runIndex].data === undefined || + !workflowRunData[parentNodeName][runIndex].data!.hasOwnProperty(inputName) + ) { + executeData.data = {}; + } else { + executeData.data = workflowRunData[parentNodeName][runIndex].data!; + if (workflowRunData[currentNode] && workflowRunData[currentNode][runIndex]) { + executeData.source = { + [inputName]: workflowRunData[currentNode][runIndex].source!, + }; + } else { + // The current node did not get executed in UI yet so build data manually + executeData.source = { + [inputName]: [ + { + previousNode: parentNodeName, + }, + ], + }; + } + } + } + + return executeData; +} + export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showMessage).extend({ computed: { ...mapStores( @@ -91,154 +442,19 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM }, }, methods: { - executeData( - parentNode: string[], - currentNode: string, - inputName: string, - runIndex: number, - ): IExecuteData { - const executeData = { - node: {}, - data: {}, - source: null, - } as IExecuteData; - - if (parentNode.length) { - // Add the input data to be able to also resolve the short expression format - // which does not use the node name - const parentNodeName = parentNode[0]; - - const parentPinData = this.workflowsStore.getPinData![parentNodeName]; - - // populate `executeData` from `pinData` - - if (parentPinData) { - executeData.data = { main: [parentPinData] }; - executeData.source = { main: [{ previousNode: parentNodeName }] }; - - return executeData; - } - - // populate `executeData` from `runData` - - const workflowRunData = this.workflowsStore.getWorkflowRunData; - if (workflowRunData === null) { - return executeData; - } - - if ( - !workflowRunData[parentNodeName] || - workflowRunData[parentNodeName].length <= runIndex || - !workflowRunData[parentNodeName][runIndex] || - !workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') || - workflowRunData[parentNodeName][runIndex].data === undefined || - !workflowRunData[parentNodeName][runIndex].data!.hasOwnProperty(inputName) - ) { - executeData.data = {}; - } else { - executeData.data = workflowRunData[parentNodeName][runIndex].data!; - if (workflowRunData[currentNode] && workflowRunData[currentNode][runIndex]) { - executeData.source = { - [inputName]: workflowRunData[currentNode][runIndex].source!, - }; - } else { - // The current node did not get executed in UI yet so build data manually - executeData.source = { - [inputName]: [ - { - previousNode: parentNodeName, - }, - ], - }; - } - } - } - - return executeData; - }, - // Returns connectionInputData to be able to execute an expression. - connectionInputData( - parentNode: string[], - currentNode: string, - inputName: string, - runIndex: number, - nodeConnection: INodeConnection = { sourceIndex: 0, destinationIndex: 0 }, - ): INodeExecutionData[] | null { - let connectionInputData: INodeExecutionData[] | null = null; - const executeData = this.executeData(parentNode, currentNode, inputName, runIndex); - if (parentNode.length) { - if ( - !Object.keys(executeData.data).length || - executeData.data[inputName].length <= nodeConnection.sourceIndex - ) { - connectionInputData = []; - } else { - connectionInputData = executeData.data![inputName][nodeConnection.sourceIndex]; - - if (connectionInputData !== null) { - // Update the pairedItem information on items - connectionInputData = connectionInputData.map((item, itemIndex) => { - return { - ...item, - pairedItem: { - item: itemIndex, - input: nodeConnection.destinationIndex, - }, - }; - }); - } - } - } - - const parentPinData = parentNode.reduce( - (acc: INodeExecutionData[], parentNodeName, index) => { - const pinData = this.workflowsStore.pinDataByNodeName(parentNodeName); - - if (pinData) { - acc.push({ - json: pinData[0], - pairedItem: { - item: index, - input: 1, - }, - }); - } - - return acc; - }, - [], - ); - - if (parentPinData.length > 0) { - if (connectionInputData && connectionInputData.length > 0) { - parentPinData.forEach((parentPinDataEntry) => { - connectionInputData![0].json = { - ...connectionInputData![0].json, - ...parentPinDataEntry.json, - }; - }); - } else { - connectionInputData = parentPinData; - } - } + // executeData - 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[] = []; + // connectionInputData - for (const node of nodes) { - returnNodes.push(Object.assign({}, node)); - } + // getNodes - return returnNodes; - }, + resolveParameter, + getCurrentWorkflow, + getNodes, + getWorkflow, + getNodeTypes, + connectionInputData, + executeData, // Returns data about nodeTypes which have a "maxNodes" limit set. // For each such type does it return how high the limit is, how many @@ -352,70 +568,6 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM return workflowIssues; }, - getNodeTypes(): INodeTypes { - const nodeTypes: INodeTypes = { - nodeTypes: {}, - init: async (nodeTypes?: INodeTypeData): Promise => {}, - getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => { - const nodeTypeDescription = this.nodeTypesStore.getNodeType(nodeType, version); - - if (nodeTypeDescription === null) { - return undefined; - } - - return { - description: nodeTypeDescription, - // As we do not have the trigger/poll functions available in the frontend - // we use the information available to figure out what are trigger nodes - // @ts-ignore - trigger: - (![ERROR_TRIGGER_NODE_TYPE, START_NODE_TYPE].includes(nodeType) && - nodeTypeDescription.inputs.length === 0 && - !nodeTypeDescription.webhooks) || - undefined, - }; - }, - }; - - return nodeTypes; - }, - - getCurrentWorkflow(copyData?: boolean): Workflow { - const nodes = this.getNodes(); - const connections = this.workflowsStore.allConnections; - const cacheKey = JSON.stringify({ nodes, connections }); - if (!copyData && cachedWorkflow && cacheKey === cachedWorkflowKey) { - return cachedWorkflow; - } - cachedWorkflowKey = cacheKey; - - return this.getWorkflow(nodes, connections, copyData); - }, - - // Returns a workflow instance. - getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow { - const nodeTypes = this.getNodeTypes(); - let workflowId: string | undefined = this.workflowsStore.workflowId; - if (workflowId && workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) { - workflowId = undefined; - } - - const workflowName = this.workflowsStore.workflowName; - - cachedWorkflow = new Workflow({ - id: workflowId, - name: workflowName, - nodes: copyData ? deepCopy(nodes) : nodes, - connections: copyData ? deepCopy(connections) : connections, - active: false, - nodeTypes, - settings: this.workflowsStore.workflowSettings, - pinData: this.workflowsStore.pinData, - }); - - return cachedWorkflow; - }, - // Returns the currently loaded workflow as JSON. getWorkflowDataToSave(): Promise { const workflowNodes = this.workflowsStore.allNodes; @@ -584,143 +736,6 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, node, path, isFullPath); }, - resolveParameter( - parameter: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[], - opts: { - targetItem?: TargetItem; - inputNodeName?: string; - inputRunIndex?: number; - inputBranchIndex?: number; - } = {}, - ): IDataObject | null { - let itemIndex = opts?.targetItem?.itemIndex || 0; - - const inputName = 'main'; - const activeNode = this.ndvStore.activeNode; - const workflow = this.getCurrentWorkflow(); - const workflowRunData = this.workflowsStore.getWorkflowRunData; - let parentNode = workflow.getParentNodes(activeNode?.name, inputName, 1); - const executionData = this.workflowsStore.getWorkflowExecution; - - if (opts?.inputNodeName && !parentNode.includes(opts.inputNodeName)) { - return null; - } - - let runIndexParent = opts?.inputRunIndex ?? 0; - const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]); - if (opts.targetItem && opts?.targetItem?.nodeName === activeNode.name && executionData) { - const sourceItems = getSourceItems(executionData, opts.targetItem); - if (!sourceItems.length) { - return null; - } - parentNode = [sourceItems[0].nodeName]; - runIndexParent = sourceItems[0].runIndex; - itemIndex = sourceItems[0].itemIndex; - if (nodeConnection) { - nodeConnection.sourceIndex = sourceItems[0].outputIndex; - } - } else { - parentNode = opts.inputNodeName ? [opts.inputNodeName] : parentNode; - if (nodeConnection) { - nodeConnection.sourceIndex = opts.inputBranchIndex ?? nodeConnection.sourceIndex; - } - - if (opts?.inputRunIndex === undefined && workflowRunData !== null && parentNode.length) { - const firstParentWithWorkflowRunData = parentNode.find( - (parentNodeName) => workflowRunData[parentNodeName], - ); - if (firstParentWithWorkflowRunData) { - runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1; - } - } - } - - let connectionInputData = this.connectionInputData( - parentNode, - activeNode.name, - inputName, - runIndexParent, - nodeConnection, - ); - - let runExecutionData: IRunExecutionData; - if (executionData === null || !executionData.data) { - runExecutionData = { - resultData: { - runData: {}, - }, - }; - } else { - runExecutionData = executionData.data; - } - - parentNode.forEach((parentNodeName) => { - const pinData: IPinData[string] = this.workflowsStore.pinDataByNodeName(parentNodeName); - - if (pinData) { - runExecutionData = { - ...runExecutionData, - resultData: { - ...runExecutionData.resultData, - runData: { - ...runExecutionData.resultData.runData, - [parentNodeName]: [ - { - startTime: new Date().valueOf(), - executionTime: 0, - source: [], - data: { - main: [pinData.map((data) => ({ json: data }))], - }, - }, - ], - }, - }, - }; - } - }); - - if (connectionInputData === null) { - connectionInputData = []; - } - - const additionalKeys: IWorkflowDataProxyAdditionalKeys = { - $execution: { - id: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, - mode: 'test', - resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, - }, - - // deprecated - $executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, - $resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, - }; - - let runIndexCurrent = opts?.targetItem?.runIndex ?? 0; - if ( - opts?.targetItem === undefined && - workflowRunData !== null && - workflowRunData[activeNode.name] - ) { - runIndexCurrent = workflowRunData[activeNode.name].length - 1; - } - const executeData = this.executeData(parentNode, activeNode.name, inputName, runIndexCurrent); - - return workflow.expression.getParameterValue( - parameter, - runExecutionData, - runIndexCurrent, - itemIndex, - activeNode.name, - connectionInputData, - 'manual', - this.rootStore.timezone, - additionalKeys, - executeData, - false, - ) as IDataObject; - }, - resolveExpression( expression: string, siblingParameters: INodeParameters = {}, @@ -736,13 +751,13 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM __xxxxxxx__: expression, ...siblingParameters, }; - const returnData: IDataObject | null = this.resolveParameter(parameters, opts); + const returnData: IDataObject | null = resolveParameter(parameters, opts); if (!returnData) { return null; } if (typeof returnData['__xxxxxxx__'] === 'object') { - const workflow = this.getCurrentWorkflow(); + const workflow = getCurrentWorkflow(); return workflow.expression.convertObjectValueToString(returnData['__xxxxxxx__'] as object); } return returnData['__xxxxxxx__']; @@ -985,7 +1000,7 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM this.uiStore.stateIsDirty = false; this.$externalHooks().run('workflow.afterUpdate', { workflowData }); - this.getCurrentWorkflow(true); // refresh cache + getCurrentWorkflow(true); // refresh cache return true; } catch (e) { this.uiStore.removeActiveAction('workflowSaving'); @@ -1082,3 +1097,11 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM }, }, }); + +export const myMixin = { + methods: { + foo() { + console.log('hi'); + }, + }, +}; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts new file mode 100644 index 0000000000000..1f569aa69a224 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts @@ -0,0 +1,87 @@ +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') { + const propertyNamesToSkip = new Set(['constructor', 'get']); + + const entries = Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) + .filter(([propertyName]) => !propertyNamesToSkip.has(propertyName)) + .sort(([first], [second]) => first.localeCompare(second)); + + return entries.map(([propertyName, descriptor]) => { + const isFunction = typeof descriptor.value === 'function'; + + const option: Completion = { + label: isFunction ? `${propertyName}()` : propertyName, + type: isFunction ? 'function' : 'keyword', + }; + + const info = i18n.luxonInstance[propertyName]; + + if (info) option.info = info; + + return option; + }); + } + + if (toResolve === 'DateTime') { + const propertyNamesToSkip = new Set(['prototype', 'name', 'length']); + + return Object.entries(Object.getOwnPropertyDescriptors(DateTime)) + .filter( + ([propertyName]) => !propertyNamesToSkip.has(propertyName) && !propertyName.includes('_'), + ) + .map(([propertyName, descriptor]) => { + const isFunction = typeof descriptor.value === 'function'; + + const option: Completion = { + label: isFunction ? `${propertyName}()` : propertyName, + type: isFunction ? 'function' : 'keyword', + }; + + const info = i18n.luxonStatic[propertyName]; + + if (info) option.info = info; + + return option; + }); + } + + return []; +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts new file mode 100644 index 0000000000000..d5551d097333e --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts @@ -0,0 +1,109 @@ +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( + // @TODO: Remove redundancy with \W + /\$(input|\(.+\)|prevNode|parameter|json|execution|workflow)*(\.|\[)(\w|\.|\(|\)|\[\d\]|\[|\]|\W)*/, + ); // ^ node selector ^ special char + + 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('.'); + + const proxy = resolveParameter(`={{ ${toResolve} }}`); + + if (!proxy || typeof proxy !== 'object' || Array.isArray(proxy)) return null; + + let options: Completion[] = []; + + try { + options = generateOptions(toResolve, proxy, word); + } catch (_) { + return null; + } + + let userInputTail = ''; + + const delimiter = word.text.includes('json[') ? 'json[' : '.'; + + userInputTail = word.text.split(delimiter).pop()!; // @TODO: Remove assertion + + 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 proxyKeysToSkip = new Set(['__ob__']); + + if (word.text.includes('json[')) { + return Object.keys(proxy.json as object) + .filter((key) => !proxyKeysToSkip.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 !proxyKeysToSkip.has(key) && isAllowedInDotNotation(key); + } + + return !proxyKeysToSkip.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 }); + } +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts new file mode 100644 index 0000000000000..8525041f8f1bd --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/root.completions.ts @@ -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; +} diff --git a/packages/editor-ui/src/plugins/codemirror/completions/utils.ts b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts new file mode 100644 index 0000000000000..a359b3be21984 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/utils.ts @@ -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); +}; diff --git a/packages/editor-ui/src/plugins/codemirror/inputHandlers/code.inputHandler.ts b/packages/editor-ui/src/plugins/codemirror/inputHandlers/code.inputHandler.ts new file mode 100644 index 0000000000000..74e026b321032 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/inputHandlers/code.inputHandler.ts @@ -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]; diff --git a/packages/editor-ui/src/plugins/codemirror/doubleBraceHandler.ts b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts similarity index 56% rename from packages/editor-ui/src/plugins/codemirror/doubleBraceHandler.ts rename to packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts index 631da62d02e01..cb3f77b5ffdb6 100644 --- a/packages/editor-ui/src/plugins/codemirror/doubleBraceHandler.ts +++ b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts @@ -1,12 +1,23 @@ -import { closeBrackets, insertBracket } from '@codemirror/autocomplete'; +import { closeBrackets, completionStatus, insertBracket } from '@codemirror/autocomplete'; import { codePointAt, codePointSize, Extension } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; -const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => { +const handler = EditorView.inputHandler.of((view, from, to, insert) => { if (view.composing || view.state.readOnly) return false; + // customization: do not autoclose tokens while autocompletion is active + if (completionStatus(view.state) !== null) return false; + const selection = view.state.selection.main; + // customization: do not autoclose square brackets prior to `.json` + if ( + insert === '[' && + view.state.doc.toString().slice(selection.from - '.json'.length, selection.to) === '.json' + ) { + return false; + } + if ( insert.length > 2 || (insert.length === 2 && codePointSize(codePointAt(insert, 0)) === 1) || @@ -22,14 +33,10 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => { view.dispatch(transaction); - /** - * Customizations to inject whitespace and braces for setup and completion - */ + // customization: inject whitespace and second brace for brace completion: {| } -> {{ | }} const cursor = view.state.selection.main.head; - // inject whitespace and second brace for brace completion: {| } -> {{ | }} - const isBraceCompletion = view.state.sliceDoc(cursor - 2, cursor) === '{{' && view.state.sliceDoc(cursor, cursor + 1) === '}'; @@ -43,7 +50,7 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => { return true; } - // inject whitespace for brace setup: empty -> {| } + // customization: inject whitespace for brace setup: empty -> {| } const isBraceSetup = view.state.sliceDoc(cursor - 1, cursor) === '{' && @@ -55,7 +62,7 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => { return true; } - // inject whitespace for brace completion from selection: {{abc|}} -> {{ abc| }} + // customization: inject whitespace for brace completion from selection: {{abc|}} -> {{ abc| }} const [range] = view.state.selection.ranges; @@ -78,6 +85,12 @@ const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => { const [_, bracketState] = closeBrackets() as readonly Extension[]; /** - * CodeMirror plugin to handle double braces `{{ }}` for resolvables in n8n expressions. + * CodeMirror plugin for (inline and modal) expression editor: + * + * - prevent token autoclosing during autocompletion (exception: `{`), + * - prevent square bracket autoclosing prior to `.json` + * - inject whitespace and braces for resolvables + * + * Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79). */ -export const doubleBraceHandler = () => [inputHandler, bracketState]; +export const expressionInputHandler = () => [handler, bracketState]; diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts b/packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts index c4eb1a75f0bb3..d97df5534f543 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts @@ -1,20 +1,43 @@ 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'; +// import { parser as jsParser } from '@lezer/javascript'; +import { javascriptLanguage } from '@codemirror/lang-javascript'; +import { completeFromList, ifIn, snippetCompletion } from '@codemirror/autocomplete'; -const parserWithNestedJsParser = n8nParser.configure({ +import { proxyCompletions } from './completions/proxy.completions'; +import { rootCompletions } from './completions/root.completions'; +import { luxonCompletions } from './completions/luxon.completions'; + +const n8nParserWithNestedJsParser = n8nParser.configure({ wrap: parseMixed((node) => { if (node.type.isTop) return null; + // @TODO: overlay still needed? return node.name === 'Resolvable' - ? { parser: jsParser, overlay: (node) => node.type.name === 'Resolvable' } + ? { parser: javascriptLanguage.parser, overlay: (node) => node.type.name === 'Resolvable' } : null; }), }); -const n8nLanguage = LRLanguage.define({ parser: parserWithNestedJsParser }); +const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser }); +// legacy: ExpressionEditorModalInput.vue export function n8nLanguageSupport() { return new LanguageSupport(n8nLanguage); } + +export function n8nLang() { + return new LanguageSupport(n8nLanguage, [ + n8nLanguage.data.of({ closeBrackets: { brackets: ['{'] } }), + n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], rootCompletions) }), + n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], proxyCompletions) }), + n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], luxonCompletions) }), + n8nLanguage.data.of({ + autocomplete: ifIn( + ['Resolvable'], + completeFromList([snippetCompletion('DateTime', { label: 'DateTime' })]), + ), + }), + ]); +} diff --git a/packages/editor-ui/src/plugins/codemirror/resolvable.completions.ts b/packages/editor-ui/src/plugins/codemirror/resolvable.completions.ts deleted file mode 100644 index f7dfec6788f93..0000000000000 --- a/packages/editor-ui/src/plugins/codemirror/resolvable.completions.ts +++ /dev/null @@ -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 = /(?('|")\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' }, - ], - }; -} diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index f68c5f1da4133..b855c4c44f498 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -325,6 +325,153 @@ export class I18nClass { }, }; } + + rootVars: Record = { + $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 = { + '$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 = { + // 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 = { + 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({ diff --git a/packages/editor-ui/src/types/completions.ts b/packages/editor-ui/src/types/completions.ts new file mode 100644 index 0000000000000..228bc0cf5ea82 --- /dev/null +++ b/packages/editor-ui/src/types/completions.ts @@ -0,0 +1 @@ +export type Word = { from: number; to: number; text: string }; diff --git a/packages/workflow/src/Expression.ts b/packages/workflow/src/Expression.ts index 6085e9625eca4..82793a6a13382 100644 --- a/packages/workflow/src/Expression.ts +++ b/packages/workflow/src/Expression.ts @@ -255,7 +255,7 @@ export class Expression { const returnValue = this.renderExpression(parameterValue, data); if (typeof returnValue === 'function') { if (returnValue.name === '$') throw new Error('invalid syntax'); - throw new Error(`${returnValue.name} is a function. Please add ()`); + throw new Error('This is a function. Please add ()'); } else if (typeof returnValue === 'string') { return returnValue; } else if (returnValue !== null && typeof returnValue === 'object') { diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index b294d72cedef5..5f6547b7aae5c 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -130,7 +130,7 @@ export class WorkflowDataProxy { return {}; // incoming connection has pinned data, so stub context object } - if (!that.runExecutionData?.executionData) { + if (!that.runExecutionData?.executionData && !that.runExecutionData?.resultData) { throw new ExpressionError( "The workflow hasn't been executed yet, so you can't reference any context data", { @@ -931,6 +931,18 @@ export class WorkflowDataProxy { return new Proxy( {}, { + ownKeys(target) { + return [ + 'pairedItem', + 'itemMatching', + 'item', + 'first', + 'last', + 'all', + 'context', + 'params', + ]; + }, get(target, property, receiver) { if (['pairedItem', 'itemMatching', 'item'].includes(property as string)) { const pairedItemMethod = (itemIndex?: number) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88d163b800d22..6615b93dcdd80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,9 +477,9 @@ importers: packages/editor-ui: specifiers: - '@codemirror/autocomplete': ^6.1.0 + '@codemirror/autocomplete': ^6.4.0 '@codemirror/commands': ^6.1.0 - '@codemirror/lang-javascript': ^6.0.2 + '@codemirror/lang-javascript': ^6.1.2 '@codemirror/language': ^6.2.1 '@codemirror/lint': ^6.0.0 '@codemirror/state': ^6.1.4 @@ -554,9 +554,9 @@ importers: vue2-touch-events: ^3.2.1 xss: ^1.0.10 dependencies: - '@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i + '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i '@codemirror/commands': 6.1.2 - '@codemirror/lang-javascript': 6.1.0 + '@codemirror/lang-javascript': 6.1.2 '@codemirror/language': 6.2.1 '@codemirror/lint': 6.0.0 '@codemirror/state': 6.1.4 @@ -2633,8 +2633,8 @@ packages: minimist: 1.2.7 dev: true - /@codemirror/autocomplete/6.3.0_wo7q3lvweq5evsu423o7qzum5i: - resolution: {integrity: sha512-4jEvh3AjJZTDKazd10J6ZsCIqaYxDMCeua5ouQxY8hlFIml+nr7le0SgBhT3SIytFBmdzPK3AUhXGuW3T79nVg==} + /@codemirror/autocomplete/6.4.0_wo7q3lvweq5evsu423o7qzum5i: + resolution: {integrity: sha512-HLF2PnZAm1s4kGs30EiqKMgD7XsYaQ0XJnMR0rofEWQ5t5D60SfqpDIkIh1ze5tiEbyUWm8+VJ6W1/erVvBMIA==} peerDependencies: '@codemirror/language': ^6.0.0 '@codemirror/state': ^6.0.0 @@ -2656,10 +2656,10 @@ packages: '@lezer/common': 1.0.1 dev: false - /@codemirror/lang-javascript/6.1.0: - resolution: {integrity: sha512-wAWEY1Wdis2cKDy9A5q/rUmzLHFbZgoupJBcGaeMMsDPi68Rm90NsmzAEODE5kW8mYdRKFhQ157WJghOZ3yYdg==} + /@codemirror/lang-javascript/6.1.2: + resolution: {integrity: sha512-OcwLfZXdQ1OHrLiIcKCn7MqZ7nx205CMKlhe+vL88pe2ymhT9+2P+QhwkYGxMICj8TDHyp8HFKVwpiisUT7iEQ==} dependencies: - '@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i + '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i '@codemirror/language': 6.2.1 '@codemirror/lint': 6.0.0 '@codemirror/state': 6.1.4 @@ -8897,7 +8897,7 @@ packages: /codemirror-lang-n8n-expression/0.1.0_zyklskjzaprvz25ee7sq7godcq: resolution: {integrity: sha512-20ss5p0koTu5bfivr1sBHYs7cpjWT2JhVB5gn7TX9WWPt+v/9p9tEcYSOyL/sm+OFuWh698Cgnmrba4efQnMCQ==} dependencies: - '@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i + '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i '@codemirror/language': 6.2.1 '@lezer/highlight': 1.1.1 '@lezer/lr': 1.2.3 From d6c7e8a7708f0dc78b3b2e7d65028a3c0e17c39f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 12:28:32 +0100 Subject: [PATCH 02/12] :test_tube: Add tests --- .../__tests__/luxon.completions.test.ts | 37 +++++ .../__tests__/proxy.completions.test.ts | 146 ++++++++++++++++++ .../completions/__tests__/proxyMocks.ts | 98 ++++++++++++ .../__tests__/root.completions.test.ts | 80 ++++++++++ .../completions/luxon.completions.ts | 67 ++++---- .../completions/proxy.completions.ts | 23 ++- 6 files changed, 403 insertions(+), 48 deletions(-) create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/luxon.completions.test.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxy.completions.test.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxyMocks.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/root.completions.test.ts diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/luxon.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/luxon.completions.test.ts new file mode 100644 index 0000000000000..ffd13032e2609 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/luxon.completions.test.ts @@ -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); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxy.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxy.completions.test.ts new file mode 100644 index 0000000000000..ec5ad40b586f8 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxy.completions.test.ts @@ -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(); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxyMocks.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxyMocks.ts new file mode 100644 index 0000000000000..af44ce4ba09eb --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/proxyMocks.ts @@ -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; + }, + }, +); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/root.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/root.completions.test.ts new file mode 100644 index 0000000000000..87c38da079627 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/root.completions.test.ts @@ -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(); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts index 1f569aa69a224..3f9e4bc8c8f93 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/luxon.completions.ts @@ -37,51 +37,48 @@ export function luxonCompletions(context: CompletionContext): CompletionResult | } function generateOptions(toResolve: string): Completion[] { - if (toResolve === '$now' || toResolve === '$today') { - const propertyNamesToSkip = new Set(['constructor', 'get']); + if (toResolve === '$now' || toResolve === '$today') return nowTodayOptions(); + if (toResolve === 'DateTime') return dateTimeOptions(); - const entries = Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) - .filter(([propertyName]) => !propertyNamesToSkip.has(propertyName)) - .sort(([first], [second]) => first.localeCompare(second)); + return []; +} - return entries.map(([propertyName, descriptor]) => { - const isFunction = typeof descriptor.value === 'function'; +export const nowTodayOptions = () => { + const SKIP_SET = new Set(['constructor', 'get']); - const option: Completion = { - label: isFunction ? `${propertyName}()` : propertyName, - type: isFunction ? 'function' : 'keyword', - }; + const entries = Object.entries(Object.getOwnPropertyDescriptors(DateTime.prototype)) + .filter(([key]) => !SKIP_SET.has(key)) + .sort(([a], [b]) => a.localeCompare(b)); - const info = i18n.luxonInstance[propertyName]; + return entries.map(([key, descriptor]) => { + const isFunction = typeof descriptor.value === 'function'; - if (info) option.info = info; + const option: Completion = { + label: isFunction ? `${key}()` : key, + type: isFunction ? 'function' : 'keyword', + }; - return option; - }); - } + const info = i18n.luxonInstance[key]; - if (toResolve === 'DateTime') { - const propertyNamesToSkip = new Set(['prototype', 'name', 'length']); + if (info) option.info = info; - return Object.entries(Object.getOwnPropertyDescriptors(DateTime)) - .filter( - ([propertyName]) => !propertyNamesToSkip.has(propertyName) && !propertyName.includes('_'), - ) - .map(([propertyName, descriptor]) => { - const isFunction = typeof descriptor.value === 'function'; + return option; + }); +}; - const option: Completion = { - label: isFunction ? `${propertyName}()` : propertyName, - type: isFunction ? 'function' : 'keyword', - }; +export const dateTimeOptions = () => { + const SKIP_SET = new Set(['prototype', 'name', 'length']); - const info = i18n.luxonStatic[propertyName]; + const keys = Object.keys(Object.getOwnPropertyDescriptors(DateTime)) + .filter((key) => !SKIP_SET.has(key) && !key.includes('_')) + .sort((a, b) => a.localeCompare(b)); - if (info) option.info = info; + return keys.map((key) => { + const option: Completion = { label: `${key}()`, type: 'function' }; + const info = i18n.luxonStatic[key]; - return option; - }); - } + if (info) option.info = info; - return []; -} + return option; + }); +}; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts index d5551d097333e..b39f68eca17c3 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts @@ -10,9 +10,8 @@ import type { Word } from '@/types/completions'; */ export function proxyCompletions(context: CompletionContext): CompletionResult | null { const word = context.matchBefore( - // @TODO: Remove redundancy with \W - /\$(input|\(.+\)|prevNode|parameter|json|execution|workflow)*(\.|\[)(\w|\.|\(|\)|\[\d\]|\[|\]|\W)*/, - ); // ^ node selector ^ special char + /\$(input|\(.+\)|prevNode|parameter|json|execution|workflow)*(\.|\[)(\w|\W)*/, + ); if (!word) return null; @@ -22,13 +21,13 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | ? word.text.slice(0, -1) : word.text.split('.').slice(0, -1).join('.'); - const proxy = resolveParameter(`={{ ${toResolve} }}`); - - if (!proxy || typeof proxy !== 'object' || Array.isArray(proxy)) return null; - 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; @@ -57,11 +56,11 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | } function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Completion[] { - const proxyKeysToSkip = new Set(['__ob__']); + const SKIP_SET = new Set(['__ob__']); if (word.text.includes('json[')) { return Object.keys(proxy.json as object) - .filter((key) => !proxyKeysToSkip.has(key)) + .filter((key) => !SKIP_SET.has(key)) .map((key) => { return { label: `'${key}']`, @@ -74,11 +73,9 @@ function generateOptions(toResolve: string, proxy: IDataObject, word: Word): Com return (Reflect.ownKeys(proxy) as string[]) .filter((key) => { - if (word.text.endsWith('json.')) { - return !proxyKeysToSkip.has(key) && isAllowedInDotNotation(key); - } + if (word.text.endsWith('json.')) return !SKIP_SET.has(key) && isAllowedInDotNotation(key); - return !proxyKeysToSkip.has(key); + return !SKIP_SET.has(key); }) .map((key) => { ensureKeyCanBeResolved(proxy, key); From 261ee62d745c8c8b4a11345e3cdfd7911ea24033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 14:32:01 +0100 Subject: [PATCH 03/12] :zap: Replace snippet with alphabetic char completions --- .../__tests__/alpha.completions.test.ts | 24 +++++++++ .../completions/alpha.completions.ts | 50 +++++++++++++++++++ .../plugins/codemirror/n8nLanguageSupport.ts | 10 ++-- 3 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts create mode 100644 packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts new file mode 100644 index 0000000000000..259caa9ac1683 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts @@ -0,0 +1,24 @@ +import { alphaCompletions } from '../alpha.completions'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; +import { setActivePinia } from 'pinia'; +import { createTestingPinia } from '@pinia/testing'; + +const EXPLICIT = false; + +test('should return alphabetic char completion options: D', () => { + setActivePinia(createTestingPinia()); + + 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); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts new file mode 100644 index 0000000000000..e46e645ae1c81 --- /dev/null +++ b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts @@ -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(/D[ateTim]*/); + + 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 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; + }); +} diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts b/packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts index d97df5534f543..eb0bb1fdc9e6d 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts @@ -3,11 +3,12 @@ import { LanguageSupport, LRLanguage } from '@codemirror/language'; import { parseMixed } from '@lezer/common'; // import { parser as jsParser } from '@lezer/javascript'; import { javascriptLanguage } from '@codemirror/lang-javascript'; -import { completeFromList, ifIn, snippetCompletion } from '@codemirror/autocomplete'; +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) => { @@ -33,11 +34,6 @@ export function n8nLang() { n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], rootCompletions) }), n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], proxyCompletions) }), n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], luxonCompletions) }), - n8nLanguage.data.of({ - autocomplete: ifIn( - ['Resolvable'], - completeFromList([snippetCompletion('DateTime', { label: 'DateTime' })]), - ), - }), + n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], alphaCompletions) }), ]); } From f30a44fc23fc26bd18883f70fedd3c3a5bf7b107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 14:40:38 +0100 Subject: [PATCH 04/12] :zap: Tighten `DateTime` check --- .../completions/__tests__/alpha.completions.test.ts | 12 ++++++++++++ .../codemirror/completions/alpha.completions.ts | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts index 259caa9ac1683..9cc421b436ed3 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts @@ -22,3 +22,15 @@ test('should return alphabetic char completion options: D', () => { expect(options.map((o) => o.label)).toEqual(['DateTime']); expect(from).toEqual(position - 1); }); + +test('should not return alphabetic char completion options: $input.D', () => { + setActivePinia(createTestingPinia()); + + 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(); +}); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts index e46e645ae1c81..1cae6151e58f1 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/alpha.completions.ts @@ -6,7 +6,7 @@ import type { Completion, CompletionContext, CompletionResult } from '@codemirro * Completions from alphabetic char, e.g. `D` -> `DateTime`. */ export function alphaCompletions(context: CompletionContext): CompletionResult | null { - const word = context.matchBefore(/D[ateTim]*/); + const word = context.matchBefore(/(\s+)D[ateTim]*/); if (!word) return null; @@ -14,7 +14,7 @@ export function alphaCompletions(context: CompletionContext): CompletionResult | let options = generateOptions(); - const { text: userInput } = word; + const userInput = word.text.trim(); if (userInput !== '' && userInput !== '$') { options = options.filter((o) => o.label.startsWith(userInput) && userInput !== o.label); From e0c3a2e60e1a9710c40fe110a7a239b1e1b65db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 14:52:47 +0100 Subject: [PATCH 05/12] :broom: Clean up `n8nLang` --- .../ExpressionEditorModalInput.vue | 3 +-- .../InlineExpressionEditorInput.vue | 7 +------ .../{n8nLanguageSupport.ts => n8nLang.ts} | 16 ++++++---------- 3 files changed, 8 insertions(+), 18 deletions(-) rename packages/editor-ui/src/plugins/codemirror/{n8nLanguageSupport.ts => n8nLang.ts} (68%) diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index e81e4e4977557..e471c0ed95165 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -11,7 +11,7 @@ import { history } from '@codemirror/commands'; import { workflowHelpers } from '@/mixins/workflowHelpers'; import { expressionManager } from '@/mixins/expressionManager'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; -import { n8nLang, n8nLanguageSupport } from '@/plugins/codemirror/n8nLanguageSupport'; +import { n8nLang } from '@/plugins/codemirror/n8nLang'; import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { inputTheme } from './theme'; @@ -37,7 +37,6 @@ export default mixins(expressionManager, workflowHelpers).extend({ mounted() { const extensions = [ inputTheme(), - // n8nLanguageSupport(), autocompletion(), n8nLang(), history(), diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index 8d9cdf1107fa8..f380d91ee21d3 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -17,7 +17,7 @@ import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { inputTheme } from './theme'; import { autocompletion, ifIn } from '@codemirror/autocomplete'; -import { n8nLang } from '@/plugins/codemirror/n8nLanguageSupport'; +import { n8nLang } from '@/plugins/codemirror/n8nLang'; export default mixins(expressionManager, workflowHelpers).extend({ name: 'InlineExpressionEditorInput', @@ -80,11 +80,6 @@ export default mixins(expressionManager, workflowHelpers).extend({ inputTheme({ isSingleLine: this.isSingleLine }), autocompletion(), n8nLang(), - // n8nLanguageSupport(), - // javascript(), - // javascriptLanguage.data.of({ - // autocomplete: myCompletions, - // }), history(), expressionInputHandler(), EditorView.lineWrapping, diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts similarity index 68% rename from packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts rename to packages/editor-ui/src/plugins/codemirror/n8nLang.ts index eb0bb1fdc9e6d..175a9fa7d0059 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLanguageSupport.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -1,7 +1,6 @@ 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'; import { javascriptLanguage } from '@codemirror/lang-javascript'; import { ifIn } from '@codemirror/autocomplete'; @@ -23,17 +22,14 @@ const n8nParserWithNestedJsParser = n8nParser.configure({ const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser }); -// legacy: ExpressionEditorModalInput.vue -export function n8nLanguageSupport() { - return new LanguageSupport(n8nLanguage); -} - export function n8nLang() { + const completionGroups = [alphaCompletions, rootCompletions, proxyCompletions, luxonCompletions]; + const options = completionGroups.map((group) => + n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) }), + ); + return new LanguageSupport(n8nLanguage, [ n8nLanguage.data.of({ closeBrackets: { brackets: ['{'] } }), - n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], rootCompletions) }), - n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], proxyCompletions) }), - n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], luxonCompletions) }), - n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], alphaCompletions) }), + ...options, ]); } From c5e87499a7642c5e281c4021b0dee82abcb313e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 14:55:10 +0100 Subject: [PATCH 06/12] :fire: Remove duplicate --- .../CodeNodeEditor/completions/jsonField.completions.ts | 3 ++- packages/editor-ui/src/components/CodeNodeEditor/utils.ts | 7 ------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/jsonField.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/jsonField.completions.ts index a769c42172dfb..a6e636ed31f4f 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/jsonField.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/jsonField.completions.ts @@ -1,11 +1,12 @@ import Vue from 'vue'; -import { isAllowedInDotNotation, escape, toVariableOption } from '../utils'; +import { escape, toVariableOption } from '../utils'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { IDataObject, IPinData, IRunData } from 'n8n-workflow'; import type { CodeNodeEditorMixin } from '../types'; import { mapStores } from 'pinia'; import { useWorkflowsStore } from '@/stores/workflows'; import { useNDVStore } from '@/stores/ndv'; +import { isAllowedInDotNotation } from '@/plugins/codemirror/completions/utils'; export const jsonFieldCompletions = (Vue as CodeNodeEditorMixin).extend({ computed: { diff --git a/packages/editor-ui/src/components/CodeNodeEditor/utils.ts b/packages/editor-ui/src/components/CodeNodeEditor/utils.ts index a252a46a85cf9..b6bd5b473038b 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/utils.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/utils.ts @@ -29,13 +29,6 @@ export function walk( return found as T[]; } -// @TODO: Dup - remove -export const isAllowedInDotNotation = (str: string) => { - const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g; - - return !DOT_NOTATION_BANNED_CHARS.test(str); -}; - export const escape = (str: string) => str .replace('$', '\\$') From fb2eb86c661327323aa10015fd913d11b564dbac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 14:59:05 +0100 Subject: [PATCH 07/12] :shirt: Remove non-null assertion --- .../src/plugins/codemirror/completions/proxy.completions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts index b39f68eca17c3..e5e9050c52924 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/proxy.completions.ts @@ -37,7 +37,7 @@ export function proxyCompletions(context: CompletionContext): CompletionResult | const delimiter = word.text.includes('json[') ? 'json[' : '.'; - userInputTail = word.text.split(delimiter).pop()!; // @TODO: Remove assertion + userInputTail = word.text.split(delimiter).pop() as string; if (userInputTail !== '') { options = options.filter((o) => o.label.startsWith(userInputTail) && userInputTail !== o.label); From 66b714c60430e488e44e8d3a3fee131f5a480a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 15:00:51 +0100 Subject: [PATCH 08/12] :zap: Confirm that `overlay` is needed --- packages/editor-ui/src/plugins/codemirror/n8nLang.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 175a9fa7d0059..40a2e13709fdb 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -13,7 +13,6 @@ const n8nParserWithNestedJsParser = n8nParser.configure({ wrap: parseMixed((node) => { if (node.type.isTop) return null; - // @TODO: overlay still needed? return node.name === 'Resolvable' ? { parser: javascriptLanguage.parser, overlay: (node) => node.type.name === 'Resolvable' } : null; From c33c08c10866cb1adef7d5799506b42ac7190c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 15:03:32 +0100 Subject: [PATCH 09/12] :fire: Remove comment --- .../InlineExpressionEditor/InlineExpressionEditorInput.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index f380d91ee21d3..7358e7ea0da72 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -13,7 +13,6 @@ import { useNDVStore } from '@/stores/ndv'; import { workflowHelpers } from '@/mixins/workflowHelpers'; import { expressionManager } from '@/mixins/expressionManager'; import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; -// import { javascript, javascriptLanguage } from '@codemirror/lang-javascript'; import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler'; import { inputTheme } from './theme'; import { autocompletion, ifIn } from '@codemirror/autocomplete'; From e677ea4fe26faa441b22ec0c2e6278b427062ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 15:06:17 +0100 Subject: [PATCH 10/12] :fire: Remove more unneeded code --- packages/editor-ui/src/mixins/workflowHelpers.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/editor-ui/src/mixins/workflowHelpers.ts b/packages/editor-ui/src/mixins/workflowHelpers.ts index 7796487518e00..bead0d1cf47ab 100644 --- a/packages/editor-ui/src/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/mixins/workflowHelpers.ts @@ -437,12 +437,6 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM }, }, methods: { - // executeData - - // connectionInputData - - // getNodes - resolveParameter, getCurrentWorkflow, getNodes, @@ -1092,11 +1086,3 @@ export const workflowHelpers = mixins(externalHooks, nodeHelpers, restApi, showM }, }, }); - -export const myMixin = { - methods: { - foo() { - console.log('hi'); - }, - }, -}; From 9ce314a0adb931d9d2eca94777afe45a69eca005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 15:09:34 +0100 Subject: [PATCH 11/12] :fire: Remove unneded Pinia setup --- .../completions/__tests__/alpha.completions.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts index 9cc421b436ed3..234bc5246a0ea 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/alpha.completions.test.ts @@ -1,14 +1,10 @@ import { alphaCompletions } from '../alpha.completions'; import { CompletionContext } from '@codemirror/autocomplete'; import { EditorState } from '@codemirror/state'; -import { setActivePinia } from 'pinia'; -import { createTestingPinia } from '@pinia/testing'; const EXPLICIT = false; test('should return alphabetic char completion options: D', () => { - setActivePinia(createTestingPinia()); - const doc = '{{ D }}'; const position = doc.indexOf('D') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); @@ -24,8 +20,6 @@ test('should return alphabetic char completion options: D', () => { }); test('should not return alphabetic char completion options: $input.D', () => { - setActivePinia(createTestingPinia()); - const doc = '{{ $input.D }}'; const position = doc.indexOf('D') + 1; const context = new CompletionContext(EditorState.create({ doc }), position, EXPLICIT); From 3c2370907c694e1a619d54872551f6b2629724ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 4 Jan 2023 15:26:25 +0100 Subject: [PATCH 12/12] :zap: Simplify syntax --- packages/editor-ui/src/plugins/codemirror/n8nLang.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts index 40a2e13709fdb..439a2a384cf8d 100644 --- a/packages/editor-ui/src/plugins/codemirror/n8nLang.ts +++ b/packages/editor-ui/src/plugins/codemirror/n8nLang.ts @@ -22,9 +22,8 @@ const n8nParserWithNestedJsParser = n8nParser.configure({ const n8nLanguage = LRLanguage.define({ parser: n8nParserWithNestedJsParser }); export function n8nLang() { - const completionGroups = [alphaCompletions, rootCompletions, proxyCompletions, luxonCompletions]; - const options = completionGroups.map((group) => - n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) }), + const options = [alphaCompletions, rootCompletions, proxyCompletions, luxonCompletions].map( + (group) => n8nLanguage.data.of({ autocomplete: ifIn(['Resolvable'], group) }), ); return new LanguageSupport(n8nLanguage, [