mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 12:44:07 -08:00
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:
parent
f73267ffa5
commit
a1259898c0
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div ref="codeNodeEditor" class="ph-no-capture" />
|
||||
<div ref="codeNodeEditor" class="ph-no-capture"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
|
@ -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 });
|
||||
}
|
|
@ -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;
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -1,16 +1,7 @@
|
|||
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',
|
||||
};
|
||||
|
||||
export const EXPRESSION_EDITOR_THEME = [
|
||||
EditorView.theme({
|
||||
const commonThemeProps = {
|
||||
'&': {
|
||||
borderWidth: 'var(--border-width-base)',
|
||||
borderStyle: 'var(--input-border-style, var(--border-style-base))',
|
||||
|
@ -20,10 +11,10 @@ export const EXPRESSION_EDITOR_THEME = [
|
|||
},
|
||||
'&.cm-focused': {
|
||||
borderColor: 'var(--color-secondary)',
|
||||
outline: 'unset !important',
|
||||
outline: '0 !important',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important",
|
||||
fontFamily: 'var(--font-family-monospace)',
|
||||
height: '220px',
|
||||
padding: 'var(--spacing-xs)',
|
||||
color: 'var(--input-font-color, var(--color-text-dark))',
|
||||
|
@ -31,32 +22,26 @@ export const EXPRESSION_EDITOR_THEME = [
|
|||
'.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 DYNAMICALLY_STYLED_RESOLVABLES_THEME = EditorView.theme({
|
||||
['.' + SYNTAX_HIGHLIGHTING_CLASSES.validResolvable]: {
|
||||
color: 'var(--color-valid-resolvable-foreground)',
|
||||
backgroundColor: 'var(--color-valid-resolvable-background)',
|
||||
export const inputTheme = () => {
|
||||
const theme = EditorView.theme(commonThemeProps);
|
||||
|
||||
return [theme, highlighter.resolvableStyle];
|
||||
};
|
||||
|
||||
export const outputTheme = () => {
|
||||
const theme = EditorView.theme({
|
||||
...commonThemeProps,
|
||||
'.cm-valid-resolvable': {
|
||||
padding: '0 2px',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
['.' + SYNTAX_HIGHLIGHTING_CLASSES.invalidResolvable]: {
|
||||
color: 'var(--color-invalid-resolvable-foreground)',
|
||||
backgroundColor: 'var(--color-invalid-resolvable-background)',
|
||||
'.cm-invalid-resolvable': {
|
||||
padding: '0 2px',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return [theme, highlighter.resolvableStyle];
|
||||
};
|
||||
|
|
16
packages/editor-ui/src/components/ExpressionFunctionIcon.vue
Normal file
16
packages/editor-ui/src/components/ExpressionFunctionIcon.vue
Normal 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>
|
293
packages/editor-ui/src/components/ExpressionParameterInput.vue
Normal file
293
packages/editor-ui/src/components/ExpressionParameterInput.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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];
|
||||
};
|
|
@ -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
|
||||
<n8n-icon
|
||||
v-if="!isReadOnly"
|
||||
icon="expand-alt"
|
||||
class="edit-window-button clickable"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</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) {
|
||||
openExpressionEditorModal() {
|
||||
if (!this.isValueExpression) return;
|
||||
|
||||
this.expressionEditDialogVisible = true;
|
||||
this.trackExpressionEditOpen();
|
||||
return;
|
||||
}
|
||||
},
|
||||
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>
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,20 +22,76 @@ 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;
|
||||
watch: {
|
||||
targetItem() {
|
||||
setTimeout(() => {
|
||||
this.$emit('change', {
|
||||
value: this.unresolvedExpression,
|
||||
segments: this.displayableSegments,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNDVStore),
|
||||
|
||||
removeColor(this.editor, this.plaintextSegments);
|
||||
unresolvedExpression(): string {
|
||||
return this.segments.reduce((acc, segment) => {
|
||||
acc += segment.kind === 'resolvable' ? segment.resolvable : segment.plaintext;
|
||||
|
||||
addColor(this.editor, this.resolvableSegments);
|
||||
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;
|
||||
|
||||
|
@ -63,65 +103,20 @@ export default mixins(workflowHelpers).extend({
|
|||
|
||||
const addsNewError = this.errorsInSuccession > prevErrorsInSuccession;
|
||||
|
||||
let delay = EVALUATION_DELAY;
|
||||
let delay = DEFAULT_EVALUATION_DELAY;
|
||||
|
||||
if (addsNewError && this.errorsInSuccession > 1 && this.errorsInSuccession < 5) {
|
||||
delay = EVALUATION_DELAY * this.errorsInSuccession;
|
||||
delay = DEFAULT_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();
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNDVStore),
|
||||
unresolvedExpression(): string {
|
||||
return this.segments.reduce((acc, segment) => {
|
||||
acc += segment.kind === 'resolvable' ? segment.resolvable : segment.plaintext;
|
||||
|
||||
return acc;
|
||||
}, '=');
|
||||
},
|
||||
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');
|
||||
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>
|
|
@ -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];
|
|
@ -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) => {
|
|
@ -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,
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
80
packages/editor-ui/src/utils/telemetryUtils.ts
Normal file
80
packages/editor-ui/src/utils/telemetryUtils.ts
Normal 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;
|
||||
}, {});
|
||||
}
|
|
@ -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') {
|
||||
|
|
|
@ -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'}
|
||||
|
|
Loading…
Reference in a new issue