feat(editor): Inline expression editor (#4814)

* WIP

* 🔥 Remove unneeded watch

*  Further setup

*  Fix import

*  Minor tweaks

* 🔥 Remove logging

* 🎨 Add some styling

* 🎨 More styling changes

* 🐛 Fix wrong marking of stale data

* 🎨 Prevent fx on dragging

* 🔥 Remove logging

*  Refine draggable target offsets

* refactor(editor): Consolidate expression management logic (#4836)

*  Extract `ExpressionFunctionIcon`

*  Simplify syntax

*  Move to mixin

* 🎨 Format

* 📘 Unify types

*  Dedup double brace handler

*  Consolidate resolvable highlighter

* 🎨 Format

*  Consolidate language pack

* ✏️ Add comment

*  Move completions to plugins

*  Partially deduplicate themes

* refactor(editor): Apply styling feedback to inline expression editor (#4846)

* 🎨 Adjust styling for expression parameter input

* 🎨 Style outputs differently

*  Set single line for RLC

* 🎨 Style both openers identically

* 🐛 Prevent defocus on resize

*  Adjust line height

* 🎨 Adjust border with for expression input

*  Fix font family for inline output

*  Set up telemetry

*  Complete telemetry

*  Simplify event source

*  Set monospaced font for inline output

* 🎨 Hide cursor on schema pill drop

* 🧪 Update snapshots

*  Consolidate editor styles

* ✏️ Add tech debt comments

*  Improve naming

*  Improve inside resolvable detection

*  Improve var naming

* 🔥 Remove outdated comment

* 🚚 Move constant to data

* ✏️ Clarify comments

* 🔥 Remove outdated comments

* 🔥 Remove unneeded try-catch

* 🔥 Remove unneeded method

* 🔥 Remove unneeded check

* 🔥 Remove `openExpression` check

* 🔥 Remove unused timeout

* 🔥 Remove commented out sections

*  Use Pinia naming convention

*  Re-evaluate on change of `ndvInputData`

* 🐛 Fix handling of `0` in number-type input

* 🐛 Surface focus and blur for mapping hints

* 🔥 Remove logging

* ✏️ Reword error

*  Change kebab-case to PascalCase

*  Refactor state fields for clarity

*  Support double bracing on selection

* 🎨 More styling

*  Miscellaneous cleanup

*  Disregard error on drop

* 🎨 Fix schema pill styling

* 🎨 More `background` to `background-color` fixes

* 🧪 Update snapshots

* 🎨 Replace non-existing var with white

* 🧪 Update snapshot

* 📦 Integrate `codemirror-lang-n8n-expression`

* 🎨 Fix formatting

* 🧪 Re-update test snapshots

* 🧪 Update selectors for inline editor

* 🔥 Remove unused test ID

* 📘 Add type for `currentNodePaneType`

*  Refactor mixin to util

*  Use `:global`

* 🔥 Remove comment

*  Add watch

*  Change import style

* 👕 Fix lint

*  Refactor preventing blur on resize

* 🔥 Remove comment

* 🧪 Re-update snapshots

* 🎨 Prettify

* 👕 Fix lint

* 🔥 Remove comment

Co-authored-by: Mutasem <mutdmour@gmail.com>
This commit is contained in:
Iván Ovejero 2022-12-14 14:43:02 +01:00 committed by GitHub
parent f73267ffa5
commit a1259898c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1285 additions and 593 deletions

View file

@ -62,7 +62,8 @@ export class WorkflowPage extends BasePage {
this.getters.canvasNodeByName(nodeTypeName).dblclick();
},
openExpressionEditor: () => {
cy.get('input[value="expression"]').parent('label').click();
cy.contains('Expression').invoke('show').click();
cy.getByTestId('expander').invoke('show').click();
},
typeIntoParameterInput: (parameterName: string, content: string) => {
this.getters.ndvParameterInput(parameterName).type(content);

View file

@ -1,5 +1,5 @@
<template>
<n8n-text :size="size" :color="color" :compact="true" class="n8n-icon">
<n8n-text :size="size" :color="color" :compact="true" class="n8n-icon" v-on="$listeners">
<font-awesome-icon :icon="icon" :spin="spin" :class="$style[size]" />
</n8n-text>
</template>

View file

@ -461,6 +461,7 @@
--font-weight-regular: 400;
--font-weight-bold: 600;
--font-family: 'Open Sans', sans-serif;
--font-family-monospace: Menlo, Consolas, 'DejaVu Sans Mono', monospace;
--spacing-5xs: 0.125rem;
--spacing-4xs: 0.25rem;

View file

@ -40,6 +40,7 @@
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/vue-fontawesome": "^2.0.2",
"axios": "^0.21.1",
"codemirror-lang-n8n-expression": "^0.1.0",
"dateformat": "^3.0.3",
"esprima-next": "5.8.4",
"fast-json-stable-stringify": "^2.1.0",

View file

@ -1,5 +1,5 @@
<template>
<div ref="codeNodeEditor" class="ph-no-capture" />
<div ref="codeNodeEditor" class="ph-no-capture"></div>
</template>
<script lang="ts">

View file

@ -7,7 +7,7 @@
<script lang="ts">
import { useNDVStore } from '@/stores/ndv';
import { mapStores } from 'pinia';
import Vue from 'vue';
import Vue, { PropType } from 'vue';
export default Vue.extend({
props: {
@ -21,8 +21,10 @@ export default Vue.extend({
type: Boolean,
},
stickyOffset: {
type: Number,
default: 0,
type: Array as PropType<number[]>,
default() {
return [0, 0];
},
},
},
data() {
@ -67,10 +69,9 @@ export default Vue.extend({
e.clientY <= dim.bottom;
if (!this.disabled && this.sticky && this.hovering) {
this.ndvStore.setDraggableStickyPos([
dim.left + this.stickyOffset,
dim.top + this.stickyOffset,
]);
const [xOffset, yOffset] = this.stickyOffset;
this.ndvStore.setDraggableStickyPos([dim.left + xOffset, dim.top + yOffset]);
}
}
},

View file

@ -43,7 +43,7 @@
</div>
</div>
<div class="expression-editor ph-no-capture">
<expression-modal-input
<ExpressionEditorModalInput
:value="value"
:isReadOnly="isReadOnly"
@change="valueChanged"
@ -58,7 +58,7 @@
{{ $locale.baseText('expressionEdit.resultOfItem1') }}
</div>
<div class="ph-no-capture">
<expression-modal-output
<ExpressionEditorModalOutput
:segments="segments"
ref="expressionResult"
data-test-id="expression-modal-output"
@ -72,8 +72,8 @@
</template>
<script lang="ts">
import ExpressionModalInput from '@/components/ExpressionEditorModal/ExpressionModalInput.vue';
import ExpressionModalOutput from '@/components/ExpressionEditorModal/ExpressionModalOutput.vue';
import ExpressionEditorModalInput from '@/components/ExpressionEditorModal/ExpressionEditorModalInput.vue';
import ExpressionEditorModalOutput from '@/components/ExpressionEditorModal/ExpressionEditorModalOutput.vue';
import VariableSelector from '@/components/VariableSelector.vue';
import { IVariableItemSelected } from '@/Interface';
@ -84,20 +84,20 @@ import { genericHelpers } from '@/mixins/genericHelpers';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
import mixins from 'vue-typed-mixins';
import { hasExpressionMapping } from '@/utils';
import { debounceHelper } from '@/mixins/debounce';
import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows';
import { useNDVStore } from '@/stores/ndv';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import type { Resolvable, Segment } from './ExpressionEditorModal/types';
import type { Segment } from '@/types/expressions';
export default mixins(externalHooks, genericHelpers, debounceHelper).extend({
name: 'ExpressionEdit',
props: ['dialogVisible', 'parameter', 'path', 'value', 'eventSource'],
components: {
ExpressionModalInput,
ExpressionModalOutput,
ExpressionEditorModalInput,
ExpressionEditorModalOutput,
VariableSelector,
},
data() {
@ -225,36 +225,13 @@ export default mixins(externalHooks, genericHelpers, debounceHelper).extend({
});
if (!newValue) {
const resolvables = this.segments.filter((s): s is Resolvable => s.kind === 'resolvable');
const errorResolvables = resolvables.filter((r) => r.error);
const exposeErrorProperties = (error: Error) => {
return Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
// @ts-ignore
return (acc[key] = error[key]), acc;
}, {});
};
const telemetryPayload = {
empty_expression: this.value === '=' || this.value === '={{}}' || !this.value,
workflow_id: this.workflowsStore.workflowId,
source: this.eventSource,
session_id: this.ndvStore.sessionId,
has_parameter: this.value.includes('$parameter'),
has_mapping: hasExpressionMapping(this.value),
node_type: this.ndvStore.activeNode?.type ?? '',
handlebar_count: resolvables.length,
handlebar_error_count: errorResolvables.length,
full_errors: errorResolvables.map((errorResolvable) => {
return errorResolvable.fullError
? {
...exposeErrorProperties(errorResolvable.fullError),
stack: errorResolvable.fullError.stack,
}
: null;
}),
short_errors: errorResolvables.map((r) => r.resolved ?? null),
};
const telemetryPayload = createExpressionTelemetryPayload(
this.segments,
this.value,
this.workflowsStore.workflowId,
this.ndvStore.sessionId,
this.ndvStore.activeNode?.type ?? '',
);
this.$telemetry.track('User closed Expression Editor', telemetryPayload);
this.$externalHooks().run('expressionEdit.closeDialog', telemetryPayload);

View file

@ -0,0 +1,113 @@
<template>
<div ref="root" class="ph-no-capture"></div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
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 { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { inputTheme } from './theme';
import type { IVariableItemSelected } from '@/Interface';
export default mixins(expressionManager, workflowHelpers).extend({
name: 'ExpressionEditorModalInput',
props: {
value: {
type: String,
},
isReadOnly: {
type: Boolean,
},
},
data() {
return {
editor: null as EditorView | null,
};
},
mounted() {
const extensions = [
inputTheme(),
n8nLanguageSupport(),
history(),
doubleBraceHandler(),
EditorView.lineWrapping,
EditorState.readOnly.of(this.isReadOnly),
EditorView.updateListener.of((viewUpdate) => {
if (!this.editor || !viewUpdate.docChanged) return;
highlighter.removeColor(this.editor, this.plaintextSegments);
highlighter.addColor(this.editor, this.resolvableSegments);
setTimeout(() => this.editor?.focus()); // prevent blur on paste
setTimeout(() => {
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
}, this.evaluationDelay);
}),
];
this.editor = new EditorView({
parent: this.$refs.root as HTMLDivElement,
state: EditorState.create({
doc: this.value.startsWith('=') ? this.value.slice(1) : this.value,
extensions,
}),
});
this.editor.focus();
highlighter.addColor(this.editor, this.resolvableSegments);
this.editor.dispatch({
selection: { anchor: this.editor.state.doc.length },
});
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
},
destroyed() {
this.editor?.destroy();
},
methods: {
itemSelected({ variable }: IVariableItemSelected) {
if (!this.editor || this.isReadOnly) return;
const OPEN_MARKER = '{{';
const CLOSE_MARKER = '}}';
const { doc, selection } = this.editor.state;
const { head } = selection.main;
const isInsideResolvable =
doc.toString().slice(0, head).includes(OPEN_MARKER) &&
doc.toString().slice(head, doc.length).includes(CLOSE_MARKER);
const insert = isInsideResolvable
? variable
: [OPEN_MARKER, variable, CLOSE_MARKER].join(' ');
this.editor.dispatch({
changes: {
from: head,
insert,
},
});
},
},
});
</script>
<style lang="scss"></style>

View file

@ -1,5 +1,5 @@
<template>
<div ref="root" class="ph-no-capture" />
<div ref="root" class="ph-no-capture"></div>
</template>
<script lang="ts">
@ -7,13 +7,13 @@ import Vue, { PropType } from 'vue';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { EXPRESSION_EDITOR_THEME } from './theme';
import { addColor, removeColor } from './colorDecorations';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { outputTheme } from './theme';
import type { Plaintext, Resolved, Segment } from './types';
import type { Plaintext, Resolved, Segment } from '@/types/expressions';
export default Vue.extend({
name: 'expression-modal-output',
name: 'ExpressionEditorModalOutput',
props: {
segments: {
type: Array as PropType<Segment[]>,
@ -27,8 +27,8 @@ export default Vue.extend({
changes: { from: 0, to: this.editor.state.doc.length, insert: this.resolvedExpression },
});
addColor(this.editor, this.resolvedSegments);
removeColor(this.editor, this.plaintextSegments);
highlighter.addColor(this.editor, this.resolvedSegments);
highlighter.removeColor(this.editor, this.plaintextSegments);
},
},
data() {
@ -37,11 +37,7 @@ export default Vue.extend({
};
},
mounted() {
const extensions = [
EXPRESSION_EDITOR_THEME,
EditorState.readOnly.of(true),
EditorView.lineWrapping,
];
const extensions = [outputTheme(), EditorState.readOnly.of(true), EditorView.lineWrapping];
this.editor = new EditorView({
parent: this.$refs.root as HTMLDivElement,

View file

@ -1,94 +0,0 @@
import { EditorView, Decoration, DecorationSet } from '@codemirror/view';
import { StateField, StateEffect } from '@codemirror/state';
import { DYNAMICALLY_STYLED_RESOLVABLES_THEME, SYNTAX_HIGHLIGHTING_CLASSES } from './theme';
import type { ColoringStateEffect, Plaintext, Resolvable, Resolved } from './types';
const stateEffects = {
addColor: StateEffect.define<ColoringStateEffect.Value>({
map: ({ from, to, kind, error }, change) => ({
from: change.mapPos(from),
to: change.mapPos(to),
kind,
error,
}),
}),
removeColor: StateEffect.define<{ from: number; to: number }>({
map: ({ from, to }, change) => ({
from: change.mapPos(from),
to: change.mapPos(to),
}),
}),
};
const marks = {
valid: Decoration.mark({ class: SYNTAX_HIGHLIGHTING_CLASSES.validResolvable }),
invalid: Decoration.mark({ class: SYNTAX_HIGHLIGHTING_CLASSES.invalidResolvable }),
};
const coloringField = StateField.define<DecorationSet>({
provide: (stateField) => EditorView.decorations.from(stateField),
create() {
return Decoration.none;
},
update(colorings, transaction) {
colorings = colorings.map(transaction.changes);
for (const txEffect of transaction.effects) {
if (txEffect.is(stateEffects.removeColor)) {
colorings = colorings.update({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
}
if (txEffect.is(stateEffects.addColor)) {
colorings = colorings.update({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
const decoration = txEffect.value.error ? marks.invalid : marks.valid;
if (txEffect.value.from === 0 && txEffect.value.to === 0) continue;
colorings = colorings.update({
add: [decoration.range(txEffect.value.from, txEffect.value.to)],
});
}
}
return colorings;
},
});
export function addColor(view: EditorView, segments: Array<Resolvable | Resolved>) {
const effects: Array<StateEffect<unknown>> = segments.map(({ from, to, kind, error }) =>
stateEffects.addColor.of({ from, to, kind, error }),
);
if (effects.length === 0) return;
if (!view.state.field(coloringField, false)) {
effects.push(
StateEffect.appendConfig.of([coloringField, DYNAMICALLY_STYLED_RESOLVABLES_THEME]),
);
}
view.dispatch({ effects });
}
export function removeColor(view: EditorView, segments: Plaintext[]) {
const effects: Array<StateEffect<unknown>> = segments.map(({ from, to }) =>
stateEffects.removeColor.of({ from, to }),
);
if (effects.length === 0) return;
if (!view.state.field(coloringField, false)) {
effects.push(
StateEffect.appendConfig.of([coloringField, DYNAMICALLY_STYLED_RESOLVABLES_THEME]),
);
}
view.dispatch({ effects });
}

View file

@ -1,56 +0,0 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var autocomplete = require('@codemirror/autocomplete');
var lr = require('@lezer/lr');
var language = require('@codemirror/language');
var highlight = require('@lezer/highlight');
// This file was generated by lezer-generator. You probably shouldn't edit it.
const parser = lr.LRParser.deserialize({
version: 14,
states: "nQQOPOOOOOO'#Cc'#CcOOOO'#Ca'#CaQQOPOOOOOO-E6_-E6_",
stateData: ']~OQPORPOSPO~O',
goto: 'cWPPPPPXP_QRORSRTQOR',
nodeNames: '⚠ Program Plaintext Resolvable BrokenResolvable',
maxTerm: 7,
skippedNodes: [0],
repeatNodeCount: 1,
tokenData:
'4f~RRO#o[#o#p{#p~[~aRQ~O#o[#o#pj#p~[~mRO#o[#p~[~~v~{OQ~~!OSO#o[#o#p![#p~[~~v~!a!YS~OX&PX^![^p&Ppq![qr![rs![st![tu![uv![vw![wx![xy![yz![z{![{|![|}![}!O![!O!P![!P!Q![!Q![![![!]![!]!^![!^!_![!_!`![!`!a![!a!b![!b!c![!c!}![!}#O![#O#P&d#P#Q![#Q#R![#R#S![#S#T![#T#o![#o#p![#p#q![#q#r4W#r#s![#s#y&P#y#z![#z$f&P$f$g![$g#BY&P#BY#BZ![#BZ$IS&P$IS$I_![$I_$I|&P$I|$JO![$JO$JT&P$JT$JU![$JU$KV&P$KV$KW![$KW&FU&P&FU&FV![&FV~&P~&URS~O#q&P#q#r&_#r~&P~&dOS~~&i!YS~OX&PX^![^p&Ppq![qr![rs![st![tu![uv![vw![wx![xy![yz![z{![{|![|}![}!O![!O!P![!P!Q![!Q![![![!]![!]!^![!^!_![!_!`![!`!a![!a!b![!b!c![!c!}![!}#O![#O#P&d#P#Q![#Q#R![#R#S![#S#T![#T#o![#o#p![#p#q![#q#r*X#r#s![#s#y&P#y#z![#z$f&P$f$g![$g#BY&P#BY#BZ![#BZ$IS&P$IS$I_![$I_$I|&P$I|$JO![$JO$JT&P$JT$JU![$JU$KV&P$KV$KW![$KW&FU&P&FU&FV![&FV~&P~*^RS~O#q*g#q#r0s#r~*g~*j}X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r3u#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~-j}X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r0g#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~0jRO#q*g#q#r0s#r~*g~0x}R~X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r3u#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~3xRO#q*g#q#r4R#r~*g~4WOR~~4]RS~O#q*g#q#r4R#r~*g',
tokenizers: [0],
topRules: { Program: [0, 1] },
tokenPrec: 0,
});
const parserWithMetaData = parser.configure({
props: [
language.foldNodeProp.add({
Application: language.foldInside,
}),
highlight.styleTags({
OpenMarker: highlight.tags.brace,
CloseMarker: highlight.tags.brace,
Plaintext: highlight.tags.content,
Resolvable: highlight.tags.string,
BrokenResolvable: highlight.tags.className,
}),
],
});
const n8nExpressionLanguage = language.LRLanguage.define({
parser: parserWithMetaData,
languageData: {
commentTokens: { line: ';' },
},
});
const completions = n8nExpressionLanguage.data.of({
autocomplete: autocomplete.completeFromList([{ label: 'abcdefg', type: 'keyword' }]),
});
function n8nExpression() {
return new language.LanguageSupport(n8nExpressionLanguage, [completions]);
}
exports.n8nExpression = n8nExpression;
exports.n8nExpressionLanguage = n8nExpressionLanguage;
exports.parserWithMetaData = parserWithMetaData;

View file

@ -1,5 +0,0 @@
import { LRLanguage, LanguageSupport } from '@codemirror/language';
declare const parserWithMetaData: import('@lezer/lr').LRParser;
declare const n8nExpressionLanguage: LRLanguage;
declare function n8nExpression(): LanguageSupport;
export { parserWithMetaData, n8nExpressionLanguage, n8nExpression };

View file

@ -1,5 +0,0 @@
import { LRLanguage, LanguageSupport } from '@codemirror/language';
declare const parserWithMetaData: import('@lezer/lr').LRParser;
declare const n8nExpressionLanguage: LRLanguage;
declare function n8nExpression(): LanguageSupport;
export { parserWithMetaData, n8nExpressionLanguage, n8nExpression };

View file

@ -1,50 +0,0 @@
import { completeFromList } from '@codemirror/autocomplete';
import { LRParser } from '@lezer/lr';
import { foldNodeProp, foldInside, LRLanguage, LanguageSupport } from '@codemirror/language';
import { styleTags, tags } from '@lezer/highlight';
// This file was generated by lezer-generator. You probably shouldn't edit it.
const parser = LRParser.deserialize({
version: 14,
states: "nQQOPOOOOOO'#Cc'#CcOOOO'#Ca'#CaQQOPOOOOOO-E6_-E6_",
stateData: ']~OQPORPOSPO~O',
goto: 'cWPPPPPXP_QRORSRTQOR',
nodeNames: '⚠ Program Plaintext Resolvable BrokenResolvable',
maxTerm: 7,
skippedNodes: [0],
repeatNodeCount: 1,
tokenData:
'4f~RRO#o[#o#p{#p~[~aRQ~O#o[#o#pj#p~[~mRO#o[#p~[~~v~{OQ~~!OSO#o[#o#p![#p~[~~v~!a!YS~OX&PX^![^p&Ppq![qr![rs![st![tu![uv![vw![wx![xy![yz![z{![{|![|}![}!O![!O!P![!P!Q![!Q![![![!]![!]!^![!^!_![!_!`![!`!a![!a!b![!b!c![!c!}![!}#O![#O#P&d#P#Q![#Q#R![#R#S![#S#T![#T#o![#o#p![#p#q![#q#r4W#r#s![#s#y&P#y#z![#z$f&P$f$g![$g#BY&P#BY#BZ![#BZ$IS&P$IS$I_![$I_$I|&P$I|$JO![$JO$JT&P$JT$JU![$JU$KV&P$KV$KW![$KW&FU&P&FU&FV![&FV~&P~&URS~O#q&P#q#r&_#r~&P~&dOS~~&i!YS~OX&PX^![^p&Ppq![qr![rs![st![tu![uv![vw![wx![xy![yz![z{![{|![|}![}!O![!O!P![!P!Q![!Q![![![!]![!]!^![!^!_![!_!`![!`!a![!a!b![!b!c![!c!}![!}#O![#O#P&d#P#Q![#Q#R![#R#S![#S#T![#T#o![#o#p![#p#q![#q#r*X#r#s![#s#y&P#y#z![#z$f&P$f$g![$g#BY&P#BY#BZ![#BZ$IS&P$IS$I_![$I_$I|&P$I|$JO![$JO$JT&P$JT$JU![$JU$KV&P$KV$KW![$KW&FU&P&FU&FV![&FV~&P~*^RS~O#q*g#q#r0s#r~*g~*j}X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r3u#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~-j}X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r0g#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~0jRO#q*g#q#r0s#r~*g~0x}R~X^*gpq*gqr*grs*gst*gtu*guv*gvw*gwx*gxy*gyz*gz{*g{|*g|}*g}!O*g!O!P*g!P!Q*g!Q![*g![!]*g!]!^*g!^!_*g!_!`*g!`!a*g!a!b*g!b!c*g!c!}*g!}#O*g#O#P-g#P#Q*g#Q#R*g#R#S*g#S#T*g#T#o*g#o#p*g#p#q*g#q#r3u#r#s*g#y#z*g$f$g*g#BY#BZ*g$IS$I_*g$I|$JO*g$JT$JU*g$KV$KW*g&FU&FV*g~3xRO#q*g#q#r4R#r~*g~4WOR~~4]RS~O#q*g#q#r4R#r~*g',
tokenizers: [0],
topRules: { Program: [0, 1] },
tokenPrec: 0,
});
const parserWithMetaData = parser.configure({
props: [
foldNodeProp.add({
Application: foldInside,
}),
styleTags({
OpenMarker: tags.brace,
CloseMarker: tags.brace,
Plaintext: tags.content,
Resolvable: tags.string,
BrokenResolvable: tags.className,
}),
],
});
const n8nExpressionLanguage = LRLanguage.define({
parser: parserWithMetaData,
languageData: {
commentTokens: { line: ';' },
},
});
const completions = n8nExpressionLanguage.data.of({
autocomplete: completeFromList([{ label: 'abcdefg', type: 'keyword' }]),
});
function n8nExpression() {
return new LanguageSupport(n8nExpressionLanguage, [completions]);
}
export { n8nExpression, n8nExpressionLanguage, parserWithMetaData };

View file

@ -1,62 +1,47 @@
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { EditorView } from '@codemirror/view';
import { tags } from '@lezer/highlight';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
export const SYNTAX_HIGHLIGHTING_CLASSES = {
validResolvable: 'cm-valid-resolvable',
invalidResolvable: 'cm-invalid-resolvable',
brokenResolvable: 'cm-broken-resolvable',
plaintext: 'cm-plaintext',
const commonThemeProps = {
'&': {
borderWidth: 'var(--border-width-base)',
borderStyle: 'var(--input-border-style, var(--border-style-base))',
borderColor: 'var(--input-border-color, var(--border-color-base))',
borderRadius: 'var(--input-border-radius, var(--border-radius-base))',
backgroundColor: 'var(--color-expression-editor-background)',
},
'&.cm-focused': {
borderColor: 'var(--color-secondary)',
outline: '0 !important',
},
'.cm-content': {
fontFamily: 'var(--font-family-monospace)',
height: '220px',
padding: 'var(--spacing-xs)',
color: 'var(--input-font-color, var(--color-text-dark))',
},
'.cm-line': {
padding: '0',
},
};
export const EXPRESSION_EDITOR_THEME = [
EditorView.theme({
'&': {
borderWidth: 'var(--border-width-base)',
borderStyle: 'var(--input-border-style, var(--border-style-base))',
borderColor: 'var(--input-border-color, var(--border-color-base))',
borderRadius: 'var(--input-border-radius, var(--border-radius-base))',
backgroundColor: 'var(--color-expression-editor-background)',
},
'&.cm-focused': {
borderColor: 'var(--color-secondary)',
outline: 'unset !important',
},
'.cm-content': {
fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important",
height: '220px',
padding: 'var(--spacing-xs)',
color: 'var(--input-font-color, var(--color-text-dark))',
},
'.cm-line': {
padding: '0',
},
}),
syntaxHighlighting(
HighlightStyle.define([
{
tag: tags.content,
class: SYNTAX_HIGHLIGHTING_CLASSES.plaintext,
},
{
tag: tags.className,
class: SYNTAX_HIGHLIGHTING_CLASSES.brokenResolvable,
},
/**
* Resolvables are dynamically styled with
* `cm-valid-resolvable` and `cm-invalid-resolvable`
*/
]),
),
];
export const inputTheme = () => {
const theme = EditorView.theme(commonThemeProps);
export const DYNAMICALLY_STYLED_RESOLVABLES_THEME = EditorView.theme({
['.' + SYNTAX_HIGHLIGHTING_CLASSES.validResolvable]: {
color: 'var(--color-valid-resolvable-foreground)',
backgroundColor: 'var(--color-valid-resolvable-background)',
},
['.' + SYNTAX_HIGHLIGHTING_CLASSES.invalidResolvable]: {
color: 'var(--color-invalid-resolvable-foreground)',
backgroundColor: 'var(--color-invalid-resolvable-background)',
},
});
return [theme, highlighter.resolvableStyle];
};
export const outputTheme = () => {
const theme = EditorView.theme({
...commonThemeProps,
'.cm-valid-resolvable': {
padding: '0 2px',
borderRadius: '2px',
},
'.cm-invalid-resolvable': {
padding: '0 2px',
borderRadius: '2px',
},
});
return [theme, highlighter.resolvableStyle];
};

View file

@ -0,0 +1,16 @@
<template>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3.69653 4.35645L3.29907 4.80566L3.33813 4.97559H4.36157C4.15258 6.18457 3.99633 7.27441 3.69653 8.9541C3.33813 11.0967 3.13207 11.623 3.01 11.8154C2.90258 12.0059 2.74829 12.1045 2.5393 12.1045C2.30981 12.1045 1.89477 11.9229 1.67211 11.7168C1.59204 11.6621 1.49731 11.6758 1.39672 11.7422C1.19555 11.9121 1.00024 12.1738 1.00024 12.4082C0.988519 12.7246 1.41137 13 1.81469 13C2.17016 13 2.6936 12.7773 3.24438 12.2539C3.99731 11.54 4.56079 10.5605 5.03149 8.44336C5.33422 7.0918 5.4768 6.21289 5.68969 4.97656L6.96118 4.86133L7.23657 4.35645H5.80493C6.17504 2.02832 6.46411 1.68359 6.81957 1.68359C7.0559 1.68359 7.33129 1.86523 7.6477 2.22266C7.74145 2.35156 7.88207 2.33789 7.9895 2.25C8.17016 2.14258 8.39282 1.86719 8.40747 1.61719C8.41821 1.3418 8.09008 1 7.52563 1C7.01391 1 6.22973 1.3418 5.57055 2.01367C4.99243 2.62598 4.68969 3.39062 4.48071 4.35645H3.69653ZM7.76489 5.91504C8.15942 5.38965 8.39575 5.21484 8.51782 5.21484C8.64379 5.21484 8.74438 5.33887 8.9602 6.03613L9.32934 7.22656C8.61547 8.31836 8.09301 8.92676 7.77563 8.92676C7.67016 8.92676 7.56372 8.89355 7.48852 8.81934C7.4143 8.74512 7.33227 8.68359 7.25219 8.68359C6.99047 8.68359 6.66625 9.00098 6.66039 9.39453C6.65454 9.7959 6.93579 10.083 7.30493 10.083C7.93872 10.083 8.47485 9.46094 9.51 7.81152L9.81274 8.83203C10.0725 9.70898 10.3772 10.083 10.7581 10.083C11.2288 10.083 11.8616 9.68164 12.552 8.59668L12.2629 8.2666C11.8479 8.7666 11.5725 9.00098 11.4094 9.00098C11.2278 9.00098 11.0686 8.72363 10.8586 8.04199L10.4163 6.60352C10.678 6.21777 10.9358 5.89355 11.1623 5.63477C11.4319 5.32715 11.6399 5.19629 11.7815 5.19629C11.9006 5.19629 12.0041 5.24707 12.0686 5.31836C12.1536 5.41211 12.2043 5.45605 12.3049 5.45605C12.5325 5.45605 12.884 5.16699 12.8967 4.78418C12.9084 4.42871 12.6877 4.12598 12.3049 4.12598C11.7268 4.12598 11.217 4.62402 10.2356 6.08301L10.0334 5.45703C9.75024 4.57812 9.56274 4.12598 9.16821 4.12598C8.70922 4.12598 8.07836 4.69043 7.46118 5.58496L7.76489 5.91504Z"
fill="#7D838F"
/>
</svg>
</template>
<script lang="ts">
export default {
name: 'ExpressionFunctionIcon',
};
</script>
<style lang="scss"></style>

View file

@ -0,0 +1,293 @@
<template>
<div :class="$style['expression-parameter-input']" v-click-outside="onBlur">
<div :class="[$style['all-sections'], { [$style['focused']]: isFocused }]">
<div
:class="[
$style['prepend-section'],
'el-input-group__prepend',
{ [$style['squared']]: isForRecordLocator },
]"
>
<ExpressionFunctionIcon />
</div>
<InlineExpressionEditorInput
:value="value"
:isReadOnly="isReadOnly"
:targetItem="hoveringItem"
:isSingleLine="isForRecordLocator"
@focus="onFocus"
@blur="onBlur"
@change="onChange"
ref="inlineInput"
/>
<n8n-icon
v-if="!isDragging"
icon="external-link-alt"
size="xsmall"
:class="$style['expression-editor-modal-opener']"
@click="$emit('modalOpenerClick')"
data-test-id="expander"
/>
</div>
<div :class="isFocused ? $style.dropdown : $style.hidden">
<n8n-text size="small" compact :class="$style.header">
{{ $locale.baseText('parameterInput.resultForItem') }} {{ hoveringItemNumber }}
</n8n-text>
<n8n-text :class="$style.body">
<InlineExpressionEditorOutput
:value="value"
:isReadOnly="isReadOnly"
:segments="segments"
/>
</n8n-text>
<div :class="$style.footer">
<n8n-text size="small" compact>
{{ $locale.baseText('parameterInput.anythingInside') }}
</n8n-text>
<div :class="$style['expression-syntax-example']" v-text="`{{ }}`"></div>
<n8n-text size="small" compact>
{{ $locale.baseText('parameterInput.isJavaScript') }}
</n8n-text>
<n8n-link
:class="$style['learn-more']"
size="small"
underline
theme="text"
:to="expressionsDocsUrl"
>
{{ $locale.baseText('parameterInput.learnMore') }}
</n8n-link>
</div>
</div>
</div>
</template>
<script lang="ts">
import { mapStores } from 'pinia';
import Vue from 'vue';
import { useNDVStore } from '@/stores/ndv';
import { useWorkflowsStore } from '@/stores/workflows';
import InlineExpressionEditorInput from '@/components/InlineExpressionEditor/InlineExpressionEditorInput.vue';
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import ExpressionFunctionIcon from '@/components/ExpressionFunctionIcon.vue';
import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
import type { Segment } from '@/types/expressions';
import type { TargetItem } from '@/Interface';
export default Vue.extend({
name: 'ExpressionParameterInput',
components: {
InlineExpressionEditorInput,
InlineExpressionEditorOutput,
ExpressionFunctionIcon,
},
data() {
return {
isFocused: false,
segments: [] as Segment[],
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
};
},
props: {
value: {
type: String,
},
isReadOnly: {
type: Boolean,
default: false,
},
isForRecordLocator: {
type: Boolean,
default: false,
},
},
computed: {
...mapStores(useNDVStore, useWorkflowsStore),
hoveringItemNumber(): number {
return (this.hoveringItem?.itemIndex ?? 0) + 1;
},
hoveringItem(): TargetItem | null {
return this.ndvStore.hoveringItem;
},
isDragging(): boolean {
return this.ndvStore.isDraggableDragging;
},
},
methods: {
focus() {
const inlineInput = this.$refs.inlineInput as (Vue & HTMLElement) | undefined;
if (inlineInput?.$el) inlineInput.focus();
},
onFocus() {
this.isFocused = true;
this.$emit('focus');
},
onBlur(event: FocusEvent) {
if (
event.target instanceof Element &&
Array.from(event.target.classList).some((_class) => _class.includes('resizer'))
) {
return; // prevent blur on resizing
}
if (this.isDragging) return; // prevent blur on dragging
const wasFocused = this.isFocused;
this.isFocused = false;
this.$emit('blur');
if (wasFocused) {
const telemetryPayload = createExpressionTelemetryPayload(
this.segments,
this.value,
this.workflowsStore.workflowId,
this.ndvStore.sessionId,
this.ndvStore.activeNode?.type ?? '',
);
this.$telemetry.track('User closed Expression Editor', telemetryPayload);
}
},
onChange({ value, segments }: { value: string; segments: Segment[] }) {
if (this.isDragging) return;
this.segments = segments;
if (value === '=' + this.value) return; // prevent report on change of target item
this.$emit('valueChanged', value);
},
},
});
</script>
<style lang="scss" module>
.expression-parameter-input {
position: relative;
.all-sections {
height: 30px;
display: flex;
flex-direction: row;
display: inline-table;
width: 100%;
}
.prepend-section {
padding: 0;
padding-top: 2px;
width: 22px;
text-align: center;
}
.squared {
border-radius: 0;
}
}
.expression-editor-modal-opener {
position: absolute;
right: 0;
bottom: 0;
background-color: white;
padding: 3px;
line-height: 9px;
border: var(--border-base);
border-top-left-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base);
cursor: pointer;
svg {
width: 9px !important;
height: 9px;
transform: rotate(270deg);
&:hover {
color: var(--color-primary);
}
}
}
.focused > .prepend-section {
border-color: var(--color-secondary);
border-bottom-left-radius: 0;
}
.focused :global(.cm-editor) {
border-color: var(--color-secondary);
}
.focused > .expression-editor-modal-opener {
border-color: var(--color-secondary);
border-bottom-right-radius: 0;
background-color: white;
}
.hidden {
display: none;
}
.dropdown {
display: flex;
flex-direction: column;
position: absolute;
z-index: 2; // cover tooltips
background: white;
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
.header,
.body,
.footer {
padding: var(--spacing-3xs);
}
.header {
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
padding-left: var(--spacing-2xs);
padding-top: var(--spacing-2xs);
}
.body {
padding-top: 0;
padding-left: var(--spacing-2xs);
color: var(--color-text-dark);
}
.footer {
border-top: var(--border-base);
padding: var(--spacing-4xs);
padding-left: var(--spacing-2xs);
padding-top: 0;
line-height: var(--font-line-height-regular);
color: var(--color-text-base);
.expression-syntax-example {
display: inline-block;
font-size: var(--font-size-2xs);
height: var(--font-size-m);
background-color: #f0f0f0;
margin-left: var(--spacing-5xs);
margin-right: var(--spacing-5xs);
}
.learn-more {
line-height: 1;
white-space: nowrap;
}
}
}
</style>

View file

@ -0,0 +1,135 @@
<template>
<div ref="root" class="ph-no-capture"></div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { mapStores } from 'pinia';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { history } from '@codemirror/commands';
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 { inputTheme } from './theme';
export default mixins(expressionManager, workflowHelpers).extend({
name: 'InlineExpressionEditorInput',
props: {
value: {
type: String,
},
isReadOnly: {
type: Boolean,
default: false,
},
isSingleLine: {
type: Boolean,
default: false,
},
},
data() {
return {
cursorPosition: 0,
};
},
watch: {
value(newValue) {
const range = this.editor?.state.selection.ranges[0];
if (range !== undefined && range.from !== range.to) return;
try {
this.editor?.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: newValue,
},
selection: { anchor: this.cursorPosition, head: this.cursorPosition },
});
} catch (_) {
// ignore out-of-range selection error on drop
}
},
ndvInputData() {
this.editor?.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: this.value,
},
});
setTimeout(() => {
this.editor?.contentDOM.blur();
});
},
},
computed: {
...mapStores(useNDVStore),
ndvInputData(): object {
return this.ndvStore.ndvInputData;
},
},
mounted() {
const extensions = [
inputTheme({ isSingleLine: this.isSingleLine }),
n8nLanguageSupport(),
history(),
doubleBraceHandler(),
EditorView.lineWrapping,
EditorView.editable.of(!this.isReadOnly),
EditorView.domEventHandlers({
focus: () => {
this.$emit('focus');
},
}),
EditorView.updateListener.of((viewUpdate) => {
if (!this.editor || !viewUpdate.docChanged) return;
highlighter.removeColor(this.editor, this.plaintextSegments);
highlighter.addColor(this.editor, this.resolvableSegments);
this.cursorPosition = viewUpdate.view.state.selection.ranges[0].from;
setTimeout(() => {
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
}, this.evaluationDelay);
}),
];
this.editor = new EditorView({
parent: this.$refs.root as HTMLDivElement,
state: EditorState.create({
doc: this.value.startsWith('=') ? this.value.slice(1) : this.value,
extensions,
}),
});
highlighter.addColor(this.editor, this.resolvableSegments);
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
},
destroyed() {
this.editor?.destroy();
},
methods: {
focus() {
this.editor?.focus();
},
},
});
</script>
<style lang="scss"></style>

View file

@ -0,0 +1,85 @@
<template>
<div ref="root" class="ph-no-capture"></div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { outputTheme } from './theme';
import type { Plaintext, Resolved, Segment } from '@/types/expressions';
export default Vue.extend({
name: 'InlineExpressionEditorOutput',
props: {
segments: {
type: Array as PropType<Segment[]>,
},
},
watch: {
segments() {
if (!this.editor) return;
this.editor.dispatch({
changes: { from: 0, to: this.editor.state.doc.length, insert: this.resolvedExpression },
});
highlighter.addColor(this.editor, this.resolvedSegments);
highlighter.removeColor(this.editor, this.plaintextSegments);
},
},
data() {
return {
editor: null as EditorView | null,
};
},
mounted() {
this.editor = new EditorView({
parent: this.$refs.root as HTMLDivElement,
state: EditorState.create({
doc: this.resolvedExpression,
extensions: [outputTheme(), EditorState.readOnly.of(true), EditorView.lineWrapping],
}),
});
},
destroyed() {
this.editor?.destroy();
},
computed: {
resolvedExpression(): string {
return this.segments.reduce((acc, segment) => {
acc += segment.kind === 'resolvable' ? segment.resolved : segment.plaintext;
return acc;
}, '');
},
plaintextSegments(): Plaintext[] {
return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
},
resolvedSegments(): Resolved[] {
let cursor = 0;
return this.segments
.map((segment) => {
segment.from = cursor;
cursor +=
segment.kind === 'plaintext'
? segment.plaintext.length
: (segment.resolved as any).toString().length;
segment.to = cursor;
return segment;
})
.filter((segment): segment is Resolved => segment.kind === 'resolvable');
},
},
methods: {
getValue() {
return '=' + this.resolvedExpression;
},
},
});
</script>
<style lang="scss"></style>

View file

@ -0,0 +1,68 @@
import { EditorView } from '@codemirror/view';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
const commonThemeProps = {
'&.cm-focused': {
outline: '0 !important',
},
'.cm-content': {
fontFamily: 'var(--font-family-monospace)',
color: 'var(--input-font-color, var(--color-text-dark))',
},
'.cm-line': {
padding: '0',
},
};
export const inputTheme = ({ isSingleLine } = { isSingleLine: false }) => {
const theme = EditorView.theme({
...commonThemeProps,
'&': {
maxHeight: isSingleLine ? '30px' : '112px',
minHeight: '30px',
width: '100%',
fontSize: 'var(--font-size-2xs)',
padding: '0 0 0 var(--spacing-2xs)',
borderWidth: 'var(--border-width-base)',
borderStyle: 'var(--input-border-style, var(--border-style-base))',
borderColor: 'var(--input-border-color, var(--border-color-base))',
borderRadius: 'var(--input-border-radius, var(--border-radius-base))',
borderTopLeftRadius: '0',
borderBottomLeftRadius: '0',
backgroundColor: 'white',
},
'.cm-scroller': {
lineHeight: '1.68',
},
});
return [theme, highlighter.resolvableStyle];
};
export const outputTheme = () => {
const theme = EditorView.theme({
...commonThemeProps,
'&': {
maxHeight: '95px',
width: '100%',
fontSize: 'var(--font-size-2xs)',
padding: '0',
borderTopLeftRadius: '0',
borderBottomLeftRadius: '0',
backgroundColor: 'white',
},
'.cm-scroller': {
lineHeight: '1.6',
},
'.cm-valid-resolvable': {
padding: '0 2px',
borderRadius: '2px',
},
'.cm-invalid-resolvable': {
padding: '0 2px',
borderRadius: '2px',
},
});
return [theme, highlighter.resolvableStyle];
};

View file

@ -12,11 +12,7 @@
@closeDialog="closeExpressionEditDialog"
@valueChanged="expressionUpdated"
></expression-edit>
<div
class="parameter-input ignore-key-press"
:style="parameterInputWrapperStyle"
@click="openExpressionEdit"
>
<div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle">
<resource-locator
v-if="isResourceLocatorParameter"
ref="resourceLocator"
@ -32,19 +28,21 @@
:node="node"
:path="path"
@input="valueChanged"
@modalOpenerClick="openExpressionEditorModal"
@focus="setFocus"
@blur="onBlur"
@drop="onResourceLocatorDrop"
/>
<n8n-input
v-else-if="isValueExpression || droppable || forceShowExpression"
:size="inputSize"
:type="getStringInputType"
:rows="getArgument('rows')"
<ExpressionParameterInput
v-else-if="isValueExpression || forceShowExpression"
:value="expressionDisplayValue"
:title="displayTitle"
:readOnly="isReadOnly"
@keydown.stop
:isReadOnly="isReadOnly"
@valueChanged="expressionUpdated"
@modalOpenerClick="openExpressionEditorModal"
@focus="setFocus"
@blur="onBlur"
ref="inputField"
/>
<div
v-else-if="
@ -99,6 +97,7 @@
v-else
v-model="tempValue"
ref="inputField"
class="input-with-opener"
:size="inputSize"
:type="getStringInputType"
:rows="getArgument('rows')"
@ -113,15 +112,19 @@
:placeholder="getPlaceholder()"
>
<template #suffix>
<div class="expand-input-icon-container">
<font-awesome-icon
v-if="!isReadOnly"
icon="expand-alt"
class="edit-window-button clickable"
:title="$locale.baseText('parameterInput.openEditWindow')"
@click="displayEditDialog()"
/>
</div>
<n8n-icon
v-if="!isReadOnly"
icon="external-link-alt"
size="xsmall"
class="edit-window-button textarea-modal-opener"
:class="{
focused: isFocused,
invalid: !isFocused && getIssues.length > 0 && !isValueExpression,
}"
:title="$locale.baseText('parameterInput.openEditWindow')"
@click="displayEditDialog()"
@focus="setFocus"
/>
</template>
</n8n-input>
</div>
@ -329,6 +332,7 @@ import ScopesNotice from '@/components/ScopesNotice.vue';
import ParameterOptions from '@/components/ParameterOptions.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
import ResourceLocator from '@/components/ResourceLocator/ResourceLocator.vue';
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
// @ts-ignore
import PrismEditor from 'vue-prism-editor';
import TextEdit from '@/components/TextEdit.vue';
@ -362,6 +366,7 @@ export default mixins(
CodeEdit,
CodeNodeEditor,
ExpressionEdit,
ExpressionParameterInput,
NodeCredentials,
CredentialsSelect,
PrismEditor,
@ -464,6 +469,7 @@ export default mixins(
},
],
},
isFocused: false,
};
},
watch: {
@ -718,10 +724,12 @@ export default mixins(
classes['parameter-value-container'] = true;
}
if (this.isValueExpression || this.forceShowExpression) {
classes['expression'] = true;
}
if (!this.droppable && !this.activeDrop && (this.getIssues.length || this.errorHighlight)) {
if (
!this.droppable &&
!this.activeDrop &&
(this.getIssues.length > 0 || this.errorHighlight) &&
!this.isValueExpression
) {
classes['has-issues'] = true;
}
@ -890,26 +898,20 @@ export default mixins(
: value;
this.valueChanged(val);
},
openExpressionEdit() {
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
return;
}
openExpressionEditorModal() {
if (!this.isValueExpression) return;
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
},
onBlur() {
this.$emit('blur');
this.isFocused = false;
},
onResourceLocatorDrop(data: string) {
this.$emit('drop', data);
},
setFocus() {
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
return;
}
if (['json'].includes(this.parameter.type) && this.getArgument('alwaysOpenEditWindow')) {
this.displayEditDialog();
return;
@ -931,6 +933,7 @@ export default mixins(
if (this.$refs.inputField && this.$refs.inputField.$el) {
// @ts-ignore
this.$refs.inputField.focus();
this.isFocused = true;
}
});
@ -1014,8 +1017,6 @@ export default mixins(
if (command === 'resetValue') {
this.valueChanged(this.parameter.default);
} else if (command === 'openExpression') {
this.expressionEditDialogVisible = true;
} else if (command === 'addExpression') {
if (this.isResourceLocatorParameter) {
if (isResourceLocatorValue(this.value)) {
@ -1023,19 +1024,23 @@ export default mixins(
} else {
this.valueChanged({ __rl: true, value: `=${this.value}`, mode: '' });
}
} else if (
this.parameter.type === 'number' &&
(!this.value || this.value === '[Object: null]')
) {
this.valueChanged('={{ 0 }}');
} else if (this.parameter.type === 'number' || this.parameter.type === 'boolean') {
this.valueChanged(`={{${this.value}}}`);
this.valueChanged(`={{ ${this.value} }}`);
} else {
this.valueChanged(`=${this.value}`);
}
setTimeout(() => {
this.expressionEditDialogVisible = true;
this.trackExpressionEditOpen();
}, 375);
this.setFocus();
} else if (command === 'removeExpression') {
let value: NodeParameterValueType = this.expressionEvaluated;
this.isFocused = false;
if (this.parameter.type === 'multiOptions' && typeof value === 'string') {
value = (value || '')
.split(',')
@ -1201,24 +1206,13 @@ export default mixins(
background-color: #f0f0f0;
}
.expression {
textarea,
input {
cursor: pointer !important;
}
--input-border-color: var(--color-secondary-tint-1);
--input-background-color: var(--color-secondary-tint-3);
--input-font-color: var(--color-secondary);
}
.droppable {
--input-border-color: var(--color-secondary);
--input-background-color: var(--color-foreground-xlight);
--input-border-style: dashed;
textarea,
input {
input,
.cm-editor {
border-width: 1.5px;
}
}
@ -1276,4 +1270,39 @@ export default mixins(
height: 100%;
align-items: center;
}
.input-with-opener > .el-input__suffix {
right: 0;
}
.textarea-modal-opener {
position: absolute;
right: 0;
bottom: 0;
background-color: white;
padding: 3px;
line-height: 9px;
border: var(--border-base);
border-top-left-radius: var(--border-radius-base);
border-bottom-right-radius: var(--border-radius-base);
cursor: pointer;
svg {
width: 9px !important;
height: 9px;
transform: rotate(270deg);
&:hover {
color: var(--color-primary);
}
}
}
.focused {
border-color: var(--color-secondary);
}
.invalid {
border-color: var(--color-danger);
}
</style>

View file

@ -24,7 +24,7 @@
type="mapping"
:disabled="isDropDisabled"
:sticky="true"
:stickyOffset="3"
:stickyOffset="isValueExpression ? [26, 3] : [3, 3]"
@drop="onDrop"
>
<template #default="{ droppable, activeDrop }">
@ -76,7 +76,12 @@ import DraggableTarget from '@/components/DraggableTarget.vue';
import mixins from 'vue-typed-mixins';
import { showMessage } from '@/mixins/showMessage';
import { LOCAL_STORAGE_MAPPING_FLAG } from '@/constants';
import { hasExpressionMapping, isResourceLocatorValue, hasOnlyListMode } from '@/utils';
import {
hasExpressionMapping,
isResourceLocatorValue,
hasOnlyListMode,
isValueExpression,
} from '@/utils';
import ParameterInputWrapper from '@/components/ParameterInputWrapper.vue';
import { INodeParameters, INodeProperties, INodePropertyMode } from 'n8n-workflow';
import { BaseTextKey } from '@/plugins/i18n';
@ -152,6 +157,9 @@ export default mixins(showMessage).extend({
isDropDisabled(): boolean {
return this.parameter.noDataExpression || this.isReadOnly || this.isResourceLocator;
},
isValueExpression(): boolean {
return isValueExpression(this.parameter, this.value);
},
showExpressionSelector(): boolean {
return this.isResourceLocator ? !hasOnlyListMode(this.parameter) : true;
},

View file

@ -66,7 +66,7 @@
type="mapping"
:disabled="hasOnlyListMode"
:sticky="true"
:stickyOffset="4"
:stickyOffset="isValueExpression ? [26, 3] : [3, 3]"
@drop="onDrop"
>
<template #default="{ droppable, activeDrop }">
@ -78,14 +78,12 @@
}"
@keydown.stop="onKeyDown"
>
<n8n-input
v-if="isValueExpression || droppable || forceShowExpression"
type="text"
:size="inputSize"
<ExpressionParameterInput
v-if="isValueExpression || forceShowExpression"
:value="expressionDisplayValue"
:title="displayTitle"
:disabled="isReadOnly"
@keydown.stop
isForRecordLocator
@valueChanged="onInputChange"
@modalOpenerClick="$emit('modalOpenerClick')"
ref="input"
/>
<n8n-input
@ -120,6 +118,7 @@
<parameter-issues
v-if="parameterIssues && parameterIssues.length"
:issues="parameterIssues"
:class="$style['parameter-issues']"
/>
<div v-else-if="urlValue" :class="$style.openResourceLink">
<n8n-link theme="text" @click.stop="openResource(urlValue)">
@ -147,7 +146,7 @@ import {
INodePropertyMode,
NodeParameterValue,
} from 'n8n-workflow';
import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue';
import DraggableTarget from '@/components/DraggableTarget.vue';
import ExpressionEdit from '@/components/ExpressionEdit.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
@ -178,6 +177,7 @@ export default mixins(debounceHelper, workflowHelpers, nodeHelpers).extend({
components: {
DraggableTarget,
ExpressionEdit,
ExpressionParameterInput,
ParameterIssues,
ResourceLocatorDropdown,
},
@ -722,9 +722,8 @@ $--mode-selector-width: 92px;
align-items: center;
width: 100%;
div:first-child {
display: flex;
flex-grow: 1;
> div {
width: 100%;
}
}
@ -744,7 +743,6 @@ $--mode-selector-width: 92px;
.droppable {
--input-border-color: var(--color-secondary-tint-1);
--input-background-color: var(--color-secondary-tint-2);
--input-border-style: dashed;
}
@ -789,6 +787,11 @@ $--mode-selector-width: 92px;
}
.openResourceLink {
width: 25px !important;
margin-left: var(--spacing-2xs);
}
.parameter-issues {
width: 25px !important;
}
</style>

View file

@ -155,7 +155,7 @@ const onDragEnd = (el: HTMLElement) => {
span span {
color: var(--color-success);
border-color: var(--color-success-light);
background: var(--color-success-tint-3);
background-color: white;
}
}
@ -167,7 +167,7 @@ const onDragEnd = (el: HTMLElement) => {
span span {
color: var(--color-primary);
border-color: var(--color-primary-tint-1);
background: var(--color-primary-tint-3);
background-color: var(--color-primary-tint-3);
}
}
</style>

View file

@ -189,7 +189,7 @@ const getIconBySchemaType = (type: Schema['type']): string => {
padding: 0 var(--spacing-3xs);
border: 1px solid var(--color-foreground-light);
border-radius: 4px;
background: var(--color-background-xlight);
background-color: var(--color-background-xlight);
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
@ -223,7 +223,7 @@ const getIconBySchemaType = (type: Schema['type']): string => {
span {
color: var(--color-primary);
border-color: var(--color-primary-tint-1);
background: var(--color-primary-tint-3);
background-color: var(--color-primary-tint-3);
svg {
path {

View file

@ -3,34 +3,34 @@
exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
<div>
<div
class="_schemaWrapper_1x7h2_1"
class="_schemaWrapper_5xhkc_1"
>
<div
class=""
>
<div
class="_schema_1x7h2_1"
class="_schema_5xhkc_1"
>
<div
class="_item_eg159_1"
class="_item_14xdy_1"
>
<!---->
<!---->
<!---->
<!---->
<div
class="_sub_eg159_14"
class="_sub_14xdy_14"
>
<div
class="_item_eg159_1"
class="_item_14xdy_1"
style="transition-delay: 0s;"
>
<div
class="_pill_eg159_51 _mappable_eg159_70"
class="_pill_14xdy_51 _mappable_14xdy_70"
title="string"
>
<span
class="_label_eg159_89"
class="_label_14xdy_89"
data-depth="1"
data-name="name"
data-path="[\\"name\\"]"
@ -50,7 +50,7 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
</span>
</div>
<span
class="_text_eg159_100"
class="_text_14xdy_100"
>
John
</span>
@ -59,15 +59,15 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
<!---->
</div>
<div
class="_item_eg159_1"
class="_item_14xdy_1"
style="transition-delay: 0.033s;"
>
<div
class="_pill_eg159_51 _mappable_eg159_70"
class="_pill_14xdy_51 _mappable_14xdy_70"
title="number"
>
<span
class="_label_eg159_89"
class="_label_14xdy_89"
data-depth="1"
data-name="age"
data-path="[\\"age\\"]"
@ -87,7 +87,7 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
</span>
</div>
<span
class="_text_eg159_100"
class="_text_14xdy_100"
>
22
</span>
@ -96,15 +96,15 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
<!---->
</div>
<div
class="_item_eg159_1"
class="_item_14xdy_1"
style="transition-delay: 0.066s;"
>
<div
class="_pill_eg159_51 _mappable_eg159_70"
class="_pill_14xdy_51 _mappable_14xdy_70"
title="array"
>
<span
class="_label_eg159_89"
class="_label_14xdy_89"
data-depth="1"
data-name="hobbies"
data-path="[\\"hobbies\\"]"
@ -130,7 +130,7 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
type="checkbox"
/>
<label
class="_toggle_eg159_20"
class="_toggle_14xdy_20"
for="array-0-2"
>
<font-awesome-icon-stub
@ -138,18 +138,18 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
/>
</label>
<div
class="_sub_eg159_14"
class="_sub_14xdy_14"
>
<div
class="_item_eg159_1"
class="_item_14xdy_1"
style="transition-delay: 0s;"
>
<div
class="_pill_eg159_51 _mappable_eg159_70"
class="_pill_14xdy_51 _mappable_14xdy_70"
title="string"
>
<span
class="_label_eg159_89"
class="_label_14xdy_89"
data-depth="2"
data-name="string[0]"
data-path="[\\"hobbies\\"][0]"
@ -164,14 +164,14 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
hobbies
</span>
<span
class="_arrayIndex_eg159_94"
class="_arrayIndex_14xdy_94"
>
[0]
</span>
</span>
</div>
<span
class="_text_eg159_100"
class="_text_14xdy_100"
>
surfing
</span>
@ -180,15 +180,15 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
<!---->
</div>
<div
class="_item_eg159_1"
class="_item_14xdy_1"
style="transition-delay: 0.033s;"
>
<div
class="_pill_eg159_51 _mappable_eg159_70"
class="_pill_14xdy_51 _mappable_14xdy_70"
title="string"
>
<span
class="_label_eg159_89"
class="_label_14xdy_89"
data-depth="2"
data-name="string[1]"
data-path="[\\"hobbies\\"][1]"
@ -203,14 +203,14 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
hobbies
</span>
<span
class="_arrayIndex_eg159_94"
class="_arrayIndex_14xdy_94"
>
[1]
</span>
</span>
</div>
<span
class="_text_eg159_100"
class="_text_14xdy_100"
>
traveling
</span>
@ -235,7 +235,7 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = `
exports[`RunDataJsonSchema.vue > renders schema for empty data 1`] = `
<div>
<div
class="_schemaWrapper_1x7h2_1"
class="_schemaWrapper_5xhkc_1"
>
<div
class="n8n-info-tip _info_3egb8_33 _note_3egb8_16 _base_3egb8_1 _bold_3egb8_12"

View file

@ -1,35 +1,19 @@
<template>
<div ref="root" class="ph-no-capture" />
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { mapStores } from 'pinia';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { history } from '@codemirror/commands';
import { syntaxTree } from '@codemirror/language';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { useNDVStore } from '@/stores/ndv';
import { n8nLanguageSupport } from './n8nLanguageSupport';
import { braceHandler } from './braceHandler';
import { EXPRESSION_EDITOR_THEME } from './theme';
import { addColor, removeColor } from './colorDecorations';
import type { IVariableItemSelected } from '@/Interface';
import type { RawSegment, Segment, Resolvable, Plaintext } from './types';
import type { PropType } from 'vue';
import type { EditorView } from '@codemirror/view';
import type { TargetItem } from '@/Interface';
import type { Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
const EVALUATION_DELAY = 300; // ms
export default mixins(workflowHelpers).extend({
name: 'expression-modal-input',
export const expressionManager = mixins(workflowHelpers).extend({
props: {
value: {
type: String,
},
isReadOnly: {
type: Boolean,
targetItem: {
type: Object as PropType<TargetItem | null>,
},
},
data() {
@ -38,73 +22,19 @@ export default mixins(workflowHelpers).extend({
errorsInSuccession: 0,
};
},
mounted() {
const extensions = [
EXPRESSION_EDITOR_THEME,
n8nLanguageSupport(),
history(),
braceHandler(),
EditorView.lineWrapping,
EditorState.readOnly.of(this.isReadOnly),
EditorView.updateListener.of((viewUpdate) => {
if (!this.editor || !viewUpdate.docChanged) return;
removeColor(this.editor, this.plaintextSegments);
addColor(this.editor, this.resolvableSegments);
const prevErrorsInSuccession = this.errorsInSuccession;
if (this.resolvableSegments.filter((s) => s.error).length > 0) {
this.errorsInSuccession += 1;
} else {
this.errorsInSuccession = 0;
}
const addsNewError = this.errorsInSuccession > prevErrorsInSuccession;
let delay = EVALUATION_DELAY;
if (addsNewError && this.errorsInSuccession > 1 && this.errorsInSuccession < 5) {
delay = EVALUATION_DELAY * this.errorsInSuccession;
} else if (addsNewError && this.errorsInSuccession >= 5) {
delay = 0;
}
setTimeout(() => this.editor?.focus()); // prevent blur on paste
setTimeout(() => {
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
}, delay);
}),
];
this.editor = new EditorView({
parent: this.$refs.root as HTMLDivElement,
state: EditorState.create({
doc: this.value.startsWith('=') ? this.value.slice(1) : this.value,
extensions,
}),
});
this.editor.focus();
addColor(this.editor, this.resolvableSegments);
this.editor.dispatch({
selection: { anchor: this.editor.state.doc.length },
});
this.$emit('change', { value: this.unresolvedExpression, segments: this.displayableSegments });
},
destroyed() {
this.editor?.destroy();
watch: {
targetItem() {
setTimeout(() => {
this.$emit('change', {
value: this.unresolvedExpression,
segments: this.displayableSegments,
});
});
},
},
computed: {
...mapStores(useNDVStore),
unresolvedExpression(): string {
return this.segments.reduce((acc, segment) => {
acc += segment.kind === 'resolvable' ? segment.resolvable : segment.plaintext;
@ -112,16 +42,81 @@ export default mixins(workflowHelpers).extend({
return acc;
}, '=');
},
hoveringItem(): TargetItem | undefined {
return this.ndvStore.hoveringItem ?? undefined;
},
resolvableSegments(): Resolvable[] {
return this.segments.filter((s): s is Resolvable => s.kind === 'resolvable');
},
plaintextSegments(): Plaintext[] {
return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
},
segments(): Segment[] {
if (!this.editor) return [];
const rawSegments: RawSegment[] = [];
syntaxTree(this.editor.state)
.cursor()
.iterate((node) => {
if (!this.editor || node.type.name === 'Program') return;
rawSegments.push({
from: node.from,
to: node.to,
text: this.editor.state.sliceDoc(node.from, node.to),
type: node.type.name,
});
});
return rawSegments.reduce<Segment[]>((acc, segment) => {
const { from, to, text, type } = segment;
if (type === 'Resolvable') {
const { resolved, error, fullError } = this.resolve(text, this.hoveringItem);
acc.push({ kind: 'resolvable', from, to, resolvable: text, resolved, error, fullError });
return acc;
}
acc.push({ kind: 'plaintext', from, to, plaintext: text });
return acc;
}, []);
},
evaluationDelay() {
const DEFAULT_EVALUATION_DELAY = 300; // ms
const prevErrorsInSuccession = this.errorsInSuccession;
if (this.resolvableSegments.filter((s) => s.error).length > 0) {
this.errorsInSuccession += 1;
} else {
this.errorsInSuccession = 0;
}
const addsNewError = this.errorsInSuccession > prevErrorsInSuccession;
let delay = DEFAULT_EVALUATION_DELAY;
if (addsNewError && this.errorsInSuccession > 1 && this.errorsInSuccession < 5) {
delay = DEFAULT_EVALUATION_DELAY * this.errorsInSuccession;
} else if (addsNewError && this.errorsInSuccession >= 5) {
delay = 0;
}
return delay;
},
/**
* Some segments are conditionally displayed, i.e. not displayed when part of the
* expression result but displayed when the entire result.
* Some segments are conditionally displayed, i.e. not displayed when they are
* _part_ of the result, but displayed when they are the _entire_ result.
*
* Example:
* - Expression `This is a {{ null }} test` is displayed as `This is a test`.
@ -133,14 +128,13 @@ export default mixins(workflowHelpers).extend({
* - `[empty]` (from `''`, not from `undefined`)
* - `null` (from `NaN`)
*
* For these two segments, display differs based on context:
* - Date displayed as
* Exceptionally, for two segments, display differs based on context:
* - Date is displayed as
* - `Mon Nov 14 2022 17:26:13 GMT+0100 (CST)` when part of the result
* - `[Object: "2022-11-14T17:26:13.130Z"]` when the entire result
* - Non-empty array displayed as
* - Non-empty array is displayed as
* - `1,2,3` when part of the result
* - `[Array: [1, 2, 3]]` when the entire result
*
*/
displayableSegments(): Segment[] {
return this.segments
@ -173,48 +167,13 @@ export default mixins(workflowHelpers).extend({
return true;
});
},
segments(): Segment[] {
if (!this.editor) return [];
const rawSegments: RawSegment[] = [];
syntaxTree(this.editor.state)
.cursor()
.iterate((node) => {
if (!this.editor || node.type.name === 'Program') return;
rawSegments.push({
from: node.from,
to: node.to,
text: this.editor.state.sliceDoc(node.from, node.to),
type: node.type.name,
});
});
return rawSegments.reduce<Segment[]>((acc, segment) => {
const { from, to, text, type } = segment;
if (type === 'Resolvable') {
const { resolved, error, fullError } = this.resolve(text);
acc.push({ kind: 'resolvable', from, to, resolvable: text, resolved, error, fullError });
return acc;
}
// broken resolvable included in plaintext
acc.push({ kind: 'plaintext', from, to, plaintext: text });
return acc;
}, []);
},
},
methods: {
isEmptyExpression(resolvable: string) {
return /\{\{\s*\}\}/.test(resolvable);
},
resolve(resolvable: string) {
resolve(resolvable: string, targetItem?: TargetItem) {
const result: { resolved: unknown; error: boolean; fullError: Error | null } = {
resolved: undefined,
error: false,
@ -223,6 +182,7 @@ export default mixins(workflowHelpers).extend({
try {
result.resolved = this.resolveExpression('=' + resolvable, undefined, {
targetItem: targetItem ?? undefined,
inputNodeName: this.ndvStore.ndvInputNodeName,
inputRunIndex: this.ndvStore.ndvInputRunIndex,
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
@ -252,30 +212,5 @@ export default mixins(workflowHelpers).extend({
return result;
},
itemSelected({ variable }: IVariableItemSelected) {
if (!this.editor || this.isReadOnly) return;
const OPEN_MARKER = '{{';
const CLOSE_MARKER = '}}';
const { doc, selection } = this.editor.state;
const { head } = selection.main;
const beforeBraced = doc.toString().slice(0, head).includes(OPEN_MARKER);
const afterBraced = doc.toString().slice(head, doc.length).includes(CLOSE_MARKER);
this.editor.dispatch({
changes: {
from: head,
insert:
beforeBraced && afterBraced
? variable
: [OPEN_MARKER, variable, CLOSE_MARKER].join(' '),
},
});
},
},
});
</script>
<style lang="scss"></style>

View file

@ -2,7 +2,7 @@ import { closeBrackets, insertBracket } from '@codemirror/autocomplete';
import { codePointAt, codePointSize, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
const braceInputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => {
if (view.composing || view.state.readOnly) return false;
const selection = view.state.selection.main;
@ -22,10 +22,15 @@ const braceInputHandler = EditorView.inputHandler.of((view, from, to, insert) =>
view.dispatch(transaction);
// customization to rearrange spacing and cursor for expression
/**
* Customizations to inject whitespace and braces
* for resolvable setup and completion
*/
const cursor = view.state.selection.main.head;
// inject whitespace and second brace on completion: {| } -> {{ | }}
const isSecondBraceForNewExpression =
view.state.sliceDoc(cursor - 2, cursor) === '{{' &&
view.state.sliceDoc(cursor, cursor + 1) === '}';
@ -39,6 +44,8 @@ const braceInputHandler = EditorView.inputHandler.of((view, from, to, insert) =>
return true;
}
// inject whitespace on setup: empty -> {| }
const isFirstBraceForNewExpression =
view.state.sliceDoc(cursor - 1, cursor) === '{' &&
view.state.sliceDoc(cursor, cursor + 1) === '}';
@ -49,9 +56,27 @@ const braceInputHandler = EditorView.inputHandler.of((view, from, to, insert) =>
return true;
}
// when selected, surround with whitespaces on completion: {{abc}} -> {{ abc }}
const doc = view.state.doc.toString();
const openMarkerIndex = doc.lastIndexOf('{', cursor);
const closeMarkerIndex = doc.indexOf('}}', cursor);
if (openMarkerIndex !== -1 && closeMarkerIndex !== -1) {
view.dispatch(
{ changes: { from: openMarkerIndex + 1, insert: ' ' } },
{ changes: { from: closeMarkerIndex, insert: ' ' } },
);
return true;
}
return true;
});
const [_, bracketState] = closeBrackets() as readonly Extension[];
export const braceHandler = () => [braceInputHandler, bracketState];
/**
* CodeMirror plugin to handle double braces `{{ }}` for resolvables in n8n expressions.
*/
export const doubleBraceHandler = () => [inputHandler, bracketState];

View file

@ -1,7 +1,7 @@
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 { parserWithMetaData as n8nParser } from './n8nLanguagePack';
const parserWithNestedJsParser = n8nParser.configure({
wrap: parseMixed((node) => {

View file

@ -0,0 +1,131 @@
import { EditorView, Decoration, DecorationSet } from '@codemirror/view';
import { StateField, StateEffect } from '@codemirror/state';
import { tags } from '@lezer/highlight';
import { syntaxHighlighting, HighlightStyle } from '@codemirror/language';
import type { ColoringStateEffect, Plaintext, Resolvable, Resolved } from '@/types/expressions';
const cssClasses = {
validResolvable: 'cm-valid-resolvable',
invalidResolvable: 'cm-invalid-resolvable',
brokenResolvable: 'cm-broken-resolvable',
plaintext: 'cm-plaintext',
};
const resolvablesTheme = EditorView.theme({
['.' + cssClasses.validResolvable]: {
color: 'var(--color-valid-resolvable-foreground)',
backgroundColor: 'var(--color-valid-resolvable-background)',
},
['.' + cssClasses.invalidResolvable]: {
color: 'var(--color-invalid-resolvable-foreground)',
backgroundColor: 'var(--color-invalid-resolvable-background)',
},
});
const marks = {
valid: Decoration.mark({ class: cssClasses.validResolvable }),
invalid: Decoration.mark({ class: cssClasses.invalidResolvable }),
};
const coloringStateEffects = {
addColorEffect: StateEffect.define<ColoringStateEffect.Value>({
map: ({ from, to, kind, error }, change) => ({
from: change.mapPos(from),
to: change.mapPos(to),
kind,
error,
}),
}),
removeColorEffect: StateEffect.define<ColoringStateEffect.Value>({
map: ({ from, to }, change) => ({
from: change.mapPos(from),
to: change.mapPos(to),
}),
}),
};
const coloringStateField = StateField.define<DecorationSet>({
provide: (stateField) => EditorView.decorations.from(stateField),
create() {
return Decoration.none;
},
update(colorings, transaction) {
colorings = colorings.map(transaction.changes); // recalculate positions for new doc
for (const txEffect of transaction.effects) {
if (txEffect.is(coloringStateEffects.removeColorEffect)) {
colorings = colorings.update({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
}
if (txEffect.is(coloringStateEffects.addColorEffect)) {
colorings = colorings.update({
filter: (from, to) => txEffect.value.from !== from && txEffect.value.to !== to,
});
const decoration = txEffect.value.error ? marks.invalid : marks.valid;
if (txEffect.value.from === 0 && txEffect.value.to === 0) continue;
colorings = colorings.update({
add: [decoration.range(txEffect.value.from, txEffect.value.to)],
});
}
}
return colorings;
},
});
function addColor(view: EditorView, segments: Array<Resolvable | Resolved>) {
const effects: Array<StateEffect<unknown>> = segments.map(({ from, to, kind, error }) =>
coloringStateEffects.addColorEffect.of({ from, to, kind, error }),
);
if (effects.length === 0) return;
if (!view.state.field(coloringStateField, false)) {
effects.push(StateEffect.appendConfig.of([coloringStateField, resolvablesTheme]));
}
view.dispatch({ effects });
}
function removeColor(view: EditorView, segments: Plaintext[]) {
const effects: Array<StateEffect<unknown>> = segments.map(({ from, to }) =>
coloringStateEffects.removeColorEffect.of({ from, to }),
);
if (effects.length === 0) return;
if (!view.state.field(coloringStateField, false)) {
effects.push(StateEffect.appendConfig.of([coloringStateField, resolvablesTheme]));
}
view.dispatch({ effects });
}
const resolvableStyle = syntaxHighlighting(
HighlightStyle.define([
{
tag: tags.content,
class: cssClasses.plaintext,
},
{
tag: tags.className,
class: cssClasses.brokenResolvable,
},
/**
* CSS classes for valid and invalid resolvables
* dynamically applied based on state fields
*/
]),
);
export const highlighter = {
addColor,
removeColor,
resolvableStyle,
};

View file

@ -859,6 +859,10 @@
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"parameterInput.expressionResult": "e.g. {result}",
"parameterInput.anythingInside": "Anything inside",
"parameterInput.isJavaScript": "is JavaScript.",
"parameterInput.learnMore": "Learn more",
"parameterInput.resultForItem": "Result for Item",
"parameterInput.emptyString": "[empty]",
"parameterInput.customApiCall": "Custom API Call",
"parameterInput.error": "ERROR",

View file

@ -18,7 +18,7 @@ export type Resolved = Resolvable;
export namespace ColoringStateEffect {
export type Value = {
kind: 'plaintext' | 'resolvable';
error: boolean;
kind?: 'plaintext' | 'resolvable';
error?: boolean;
} & Range;
}

View file

@ -0,0 +1,80 @@
import { hasExpressionMapping } from '@/utils';
import type { Resolvable, Segment } from '@/types/expressions';
export function createExpressionTelemetryPayload(
segments: Segment[],
value: string,
workflowId: string,
sessionId: string,
activeNodeType: string,
eventSource = 'ndv',
) {
const resolvables = segments.filter((s): s is Resolvable => s.kind === 'resolvable');
const erroringResolvables = resolvables.filter((r) => r.error);
return {
empty_expression: value === '=' || value === '={{}}' || !value,
workflow_id: workflowId,
source: eventSource,
session_id: sessionId,
is_transforming_data: resolvables.some((r) => isTransformingData(r.resolvable)),
has_parameter: value.includes('$parameter'),
has_mapping: hasExpressionMapping(value),
node_type: activeNodeType,
handlebar_count: resolvables.length,
handlebar_error_count: erroringResolvables.length,
short_errors: erroringResolvables.map((r) => r.resolved ?? null),
full_errors: erroringResolvables.map((erroringResolvable) => {
if (!erroringResolvable.fullError) return null;
return {
...exposeErrorProperties(erroringResolvable.fullError),
stack: erroringResolvable.fullError.stack,
};
}),
};
}
/**
* Whether the resolvable is transforming data from another node,
* i.e. operating on `$input()`, `$json`, `$()` or `$node[]`.
*
* ```
* $input.all().
* $input.first().
* $input.last().
*
* $json['field'].
* $json["field"].
* $json.field'.
*
* $('nodeName').all().
* $('nodeName').first().
* $('nodeName').last().
*
* $("nodeName").all().
* $("nodeName").first().
* $("nodeName").last().
*
* $node['nodeName'].all().
* $node['nodeName'].first().
* $node['nodeName'].last().
*
* $node["nodeName"].all().
* $node["nodeName"].first().
* $node["nodeName"].last().
* ```
*/
function isTransformingData(resolvable: string) {
const regex =
/(\$input\.\w+\(\)\.|\$json(\[('|")|\.)\w+('|")]?\.|\$\(('|")\w+('|")\)\.\w+\(\)\.|\$node\[('|")\w+('|")\]\.\w+\(\)\.)/;
return regex.test(resolvable);
}
function exposeErrorProperties(error: Error) {
return Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
return (acc[key] = error[key as keyof Error]), acc;
}, {});
}

View file

@ -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('Expression resolved to a function. Please add "()"');
throw new Error(`${returnValue.name} is a function. Please add ()`);
} else if (typeof returnValue === 'string') {
return returnValue;
} else if (returnValue !== null && typeof returnValue === 'object') {

View file

@ -503,6 +503,7 @@ importers:
'@vitejs/plugin-vue2': ^1.1.2
axios: ^0.21.1
c8: ^7.12.0
codemirror-lang-n8n-expression: ^0.1.0
dateformat: ^3.0.3
esprima-next: 5.8.4
fast-json-stable-stringify: ^2.1.0
@ -563,6 +564,7 @@ importers:
'@fortawesome/free-solid-svg-icons': 5.15.4
'@fortawesome/vue-fontawesome': 2.0.8_tc4irwwlc7tvswdic4b5cxexom
axios: 0.21.4
codemirror-lang-n8n-expression: 0.1.0_zyklskjzaprvz25ee7sq7godcq
dateformat: 3.0.3
esprima-next: 5.8.4
fast-json-stable-stringify: 2.1.0
@ -8884,6 +8886,19 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/codemirror-lang-n8n-expression/0.1.0_zyklskjzaprvz25ee7sq7godcq:
resolution: {integrity: sha512-20ss5p0koTu5bfivr1sBHYs7cpjWT2JhVB5gn7TX9WWPt+v/9p9tEcYSOyL/sm+OFuWh698Cgnmrba4efQnMCQ==}
dependencies:
'@codemirror/autocomplete': 6.3.0_wo7q3lvweq5evsu423o7qzum5i
'@codemirror/language': 6.2.1
'@lezer/highlight': 1.1.1
'@lezer/lr': 1.2.3
transitivePeerDependencies:
- '@codemirror/state'
- '@codemirror/view'
- '@lezer/common'
dev: false
/codepage/1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}