From a1259898c01406ebd7f8d0182a6c66fd8b0c7734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 14 Dec 2022 14:43:02 +0100 Subject: [PATCH] feat(editor): Inline expression editor (#4814) * WIP * :fire: Remove unneeded watch * :zap: Further setup * :zap: Fix import * :zap: Minor tweaks * :fire: Remove logging * :art: Add some styling * :art: More styling changes * :bug: Fix wrong marking of stale data * :art: Prevent fx on dragging * :fire: Remove logging * :zap: Refine draggable target offsets * refactor(editor): Consolidate expression management logic (#4836) * :zap: Extract `ExpressionFunctionIcon` * :zap: Simplify syntax * :zap: Move to mixin * :art: Format * :blue_book: Unify types * :zap: Dedup double brace handler * :zap: Consolidate resolvable highlighter * :art: Format * :zap: Consolidate language pack * :pencil2: Add comment * :zap: Move completions to plugins * :zap: Partially deduplicate themes * refactor(editor): Apply styling feedback to inline expression editor (#4846) * :art: Adjust styling for expression parameter input * :art: Style outputs differently * :zap: Set single line for RLC * :art: Style both openers identically * :bug: Prevent defocus on resize * :zap: Adjust line height * :art: Adjust border with for expression input * :zap: Fix font family for inline output * :zap: Set up telemetry * :zap: Complete telemetry * :zap: Simplify event source * :zap: Set monospaced font for inline output * :art: Hide cursor on schema pill drop * :test_tube: Update snapshots * :zap: Consolidate editor styles * :pencil2: Add tech debt comments * :zap: Improve naming * :zap: Improve inside resolvable detection * :zap: Improve var naming * :fire: Remove outdated comment * :truck: Move constant to data * :pencil2: Clarify comments * :fire: Remove outdated comments * :fire: Remove unneeded try-catch * :fire: Remove unneeded method * :fire: Remove unneeded check * :fire: Remove `openExpression` check * :fire: Remove unused timeout * :fire: Remove commented out sections * :zap: Use Pinia naming convention * :zap: Re-evaluate on change of `ndvInputData` * :bug: Fix handling of `0` in number-type input * :bug: Surface focus and blur for mapping hints * :fire: Remove logging * :pencil2: Reword error * :zap: Change kebab-case to PascalCase * :zap: Refactor state fields for clarity * :zap: Support double bracing on selection * :art: More styling * :zap: Miscellaneous cleanup * :zap: Disregard error on drop * :art: Fix schema pill styling * :art: More `background` to `background-color` fixes * :test_tube: Update snapshots * :art: Replace non-existing var with white * :test_tube: Update snapshot * :package: Integrate `codemirror-lang-n8n-expression` * :art: Fix formatting * :test_tube: Re-update test snapshots * :test_tube: Update selectors for inline editor * :fire: Remove unused test ID * :blue_book: Add type for `currentNodePaneType` * :zap: Refactor mixin to util * :zap: Use `:global` * :fire: Remove comment * :zap: Add watch * :zap: Change import style * :shirt: Fix lint * :zap: Refactor preventing blur on resize * :fire: Remove comment * :test_tube: Re-update snapshots * :art: Prettify * :shirt: Fix lint * :fire: Remove comment Co-authored-by: Mutasem --- cypress/pages/workflow.ts | 3 +- .../src/components/N8nIcon/Icon.vue | 2 +- packages/design-system/src/css/_tokens.scss | 1 + packages/editor-ui/package.json | 1 + .../CodeNodeEditor/CodeNodeEditor.vue | 2 +- .../src/components/DraggableTarget.vue | 15 +- .../src/components/ExpressionEdit.vue | 53 +--- .../ExpressionEditorModalInput.vue | 113 +++++++ ...ut.vue => ExpressionEditorModalOutput.vue} | 20 +- .../ExpressionEditorModal/colorDecorations.ts | 94 ------ .../n8nLanguagePack/index.cjs | 56 ---- .../n8nLanguagePack/index.d.cts | 5 - .../n8nLanguagePack/index.d.ts | 5 - .../n8nLanguagePack/index.js | 50 --- .../components/ExpressionEditorModal/theme.ts | 99 +++--- .../src/components/ExpressionFunctionIcon.vue | 16 + .../components/ExpressionParameterInput.vue | 293 ++++++++++++++++++ .../InlineExpressionEditorInput.vue | 135 ++++++++ .../InlineExpressionEditorOutput.vue | 85 +++++ .../InlineExpressionEditor/theme.ts | 68 ++++ .../src/components/ParameterInput.vue | 143 +++++---- .../src/components/ParameterInputFull.vue | 12 +- .../ResourceLocator/ResourceLocator.vue | 29 +- .../src/components/RunDataSchema.vue | 4 +- .../src/components/RunDataSchemaItem.vue | 4 +- .../__snapshots__/RunDataSchema.test.ts.snap | 56 ++-- .../expressionManager.ts} | 245 ++++++--------- .../codemirror/doubleBraceHandler.ts} | 31 +- .../codemirror}/n8nLanguageSupport.ts | 2 +- .../codemirror}/resolvable.completions.ts | 0 .../codemirror/resolvableHighlighter.ts | 131 ++++++++ .../src/plugins/i18n/locales/en.json | 4 + .../types.ts => types/expressions.ts} | 4 +- .../editor-ui/src/utils/telemetryUtils.ts | 80 +++++ packages/workflow/src/Expression.ts | 2 +- pnpm-lock.yaml | 15 + 36 files changed, 1285 insertions(+), 593 deletions(-) create mode 100644 packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue rename packages/editor-ui/src/components/ExpressionEditorModal/{ExpressionModalOutput.vue => ExpressionEditorModalOutput.vue} (76%) delete mode 100644 packages/editor-ui/src/components/ExpressionEditorModal/colorDecorations.ts delete mode 100644 packages/editor-ui/src/components/ExpressionEditorModal/n8nLanguagePack/index.cjs delete mode 100644 packages/editor-ui/src/components/ExpressionEditorModal/n8nLanguagePack/index.d.cts delete mode 100644 packages/editor-ui/src/components/ExpressionEditorModal/n8nLanguagePack/index.d.ts delete mode 100644 packages/editor-ui/src/components/ExpressionEditorModal/n8nLanguagePack/index.js create mode 100644 packages/editor-ui/src/components/ExpressionFunctionIcon.vue create mode 100644 packages/editor-ui/src/components/ExpressionParameterInput.vue create mode 100644 packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue create mode 100644 packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue create mode 100644 packages/editor-ui/src/components/InlineExpressionEditor/theme.ts rename packages/editor-ui/src/{components/ExpressionEditorModal/ExpressionModalInput.vue => mixins/expressionManager.ts} (56%) rename packages/editor-ui/src/{components/ExpressionEditorModal/braceHandler.ts => plugins/codemirror/doubleBraceHandler.ts} (59%) rename packages/editor-ui/src/{components/ExpressionEditorModal => plugins/codemirror}/n8nLanguageSupport.ts (87%) rename packages/editor-ui/src/{components/ExpressionEditorModal => plugins/codemirror}/resolvable.completions.ts (100%) create mode 100644 packages/editor-ui/src/plugins/codemirror/resolvableHighlighter.ts rename packages/editor-ui/src/{components/ExpressionEditorModal/types.ts => types/expressions.ts} (89%) create mode 100644 packages/editor-ui/src/utils/telemetryUtils.ts diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index ef5cb62190..5d13bc64bc 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -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); diff --git a/packages/design-system/src/components/N8nIcon/Icon.vue b/packages/design-system/src/components/N8nIcon/Icon.vue index 1e6c9e4ae0..1329442190 100644 --- a/packages/design-system/src/components/N8nIcon/Icon.vue +++ b/packages/design-system/src/components/N8nIcon/Icon.vue @@ -1,5 +1,5 @@ diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 8ec5bc0861..944bb55f54 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -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; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 2aa39a53c6..8b135558b9 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -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", diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index 78daa5bda8..80b4dd392d 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -1,5 +1,5 @@ + + diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionModalOutput.vue b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalOutput.vue similarity index 76% rename from packages/editor-ui/src/components/ExpressionEditorModal/ExpressionModalOutput.vue rename to packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalOutput.vue index 6d40157df9..b6545b2bda 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionModalOutput.vue +++ b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalOutput.vue @@ -1,5 +1,5 @@ + + diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue new file mode 100644 index 0000000000..d2cc37b047 --- /dev/null +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue new file mode 100644 index 0000000000..53468eb5d2 --- /dev/null +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue new file mode 100644 index 0000000000..bf5c111494 --- /dev/null +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts b/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts new file mode 100644 index 0000000000..0e11f5e8c9 --- /dev/null +++ b/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts @@ -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]; +}; diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 6a598a63e3..da565e27a7 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -12,11 +12,7 @@ @closeDialog="closeExpressionEditDialog" @valueChanged="expressionUpdated" > -
+
-
@@ -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); +} diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue index de602f4911..7cf102f6af 100644 --- a/packages/editor-ui/src/components/ParameterInputFull.vue +++ b/packages/editor-ui/src/components/ParameterInputFull.vue @@ -24,7 +24,7 @@ type="mapping" :disabled="isDropDisabled" :sticky="true" - :stickyOffset="3" + :stickyOffset="isValueExpression ? [26, 3] : [3, 3]" @drop="onDrop" >