feat(editor): SQL editor overhaul (#6282)

* Draft setup
*  Implemented expression evaluation in Postgres node, minor SQL editor UI improvements, minor refacring
*  Added initial version of expression preview for SQL editor
*  Linking npm package for codemirror sql grammar instead of a local file
*  Moving expression editor wrapper elements to the component
*  Using expression preview in SQL editor
* Use SQL parser skipping whitespace
*  Added support for custom skipped segments specification
*  Fixing highlight problems with dots and expressions that resolve to zero
* 👕 Fixing linting error
*  Added current item support
*  Added expression support to more nodes with sql editor
*  Added expression support for other nodes
*  Implemented different SQL dialect support
* 🐛 Fixing hard-coded parameter names for editors
*  Fixing preview for nested queries, updating query when input data changes, adding keyboard shortcut to toggle comments
*  Adding a custom automcomplete notice for different editors
*  Updating SQL autocomplete notice
*  Added unit tests for SQL editor
*  Using latest grammar
* 🐛 Fixing code node editor rendering
* 💄 SQL preview dropdown matches editor width. Removing unnecessary css
*  Addressing PR review feedback
* 👌 Addressing PR review feedback pt2
* 👌 Added path alias for utils in nodes-base package
* 👌 Addressing more PR review feedback
*  Adding tests for `getResolvables` utility function
* Fixing lodash imports
* 👌 Better focus handling, adding more plugins to the editor, other minor imrovements
*  Not showing SQL autocomplete suggestions inside expressions
*  Using npm package for sql grammar
*  Removing autocomplete notice, adding line highlight on syntax error
* 👌 Addressing code review feedback
---------
Co-authored-by: Milorad Filipovic <milorad@n8n.io>
This commit is contained in:
Iván Ovejero
2023-06-22 16:47:28 +02:00
committed by GitHub
parent d431117c9e
commit beedfb609c
68 changed files with 653 additions and 287 deletions

View File

@@ -31,9 +31,10 @@ const cssStyleDeclaration = getComputedStyle(document.documentElement);
interface ThemeSettings {
isReadOnly?: boolean;
customMaxHeight?: string;
}
export const codeNodeEditorTheme = ({ isReadOnly }: ThemeSettings) => [
export const codeNodeEditorTheme = ({ isReadOnly, customMaxHeight }: ThemeSettings) => [
EditorView.theme({
'&': {
'font-size': BASE_STYLING.fontSize,
@@ -79,7 +80,7 @@ export const codeNodeEditorTheme = ({ isReadOnly }: ThemeSettings) => [
},
'.cm-scroller': {
overflow: 'auto',
maxHeight: '100%',
maxHeight: customMaxHeight ?? '100%',
...(isReadOnly ? {} : { minHeight: '10em' }),
},
'.cm-diagnosticAction': {

View File

@@ -31,36 +31,13 @@
/>
</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>
<InlineExpressionEditorOutput
:segments="segments"
:value="value"
:isReadOnly="isReadOnly"
:visible="isFocused"
:hoveringItemNumber="hoveringItemNumber"
/>
</div>
</template>
@@ -74,7 +51,6 @@ import InlineExpressionEditorInput from '@/components/InlineExpressionEditor/Inl
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';
@@ -92,7 +68,6 @@ export default defineComponent({
return {
isFocused: false,
segments: [] as Segment[],
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
};
},
props: {
@@ -114,14 +89,10 @@ export default defineComponent({
computed: {
...mapStores(useNDVStore, useWorkflowsStore),
hoveringItemNumber(): number {
return (this.hoveringItem?.itemIndex ?? 0) + 1;
return this.ndvStore.hoveringItemNumber;
},
hoveringItem(): TargetItem | null {
if (this.ndvStore.isInputParentOfActiveNode) {
return this.ndvStore.hoveringItem;
}
return null;
return this.ndvStore.getHoveringItem;
},
isDragging(): boolean {
return this.ndvStore.isDraggableDragging;
@@ -241,64 +212,4 @@ export default defineComponent({
border-bottom-right-radius: 0;
background-color: white;
}
.hidden {
display: none;
}
.dropdown {
display: flex;
flex-direction: column;
position: absolute;
z-index: 2; // cover tooltips
background: white;
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
.header,
.body,
.footer {
padding: var(--spacing-3xs);
}
.header {
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
padding-left: var(--spacing-2xs);
padding-top: var(--spacing-2xs);
}
.body {
padding-top: 0;
padding-left: var(--spacing-2xs);
color: var(--color-text-dark);
}
.footer {
border-top: var(--border-base);
padding: var(--spacing-4xs);
padding-left: var(--spacing-2xs);
padding-top: 0;
line-height: var(--font-line-height-regular);
color: var(--color-text-base);
.expression-syntax-example {
display: inline-block;
font-size: var(--font-size-2xs);
height: var(--font-size-m);
background-color: #f0f0f0;
margin-left: var(--spacing-5xs);
margin-right: var(--spacing-5xs);
}
.learn-more {
line-height: 1;
white-space: nowrap;
}
}
}
</style>

View File

@@ -1,5 +1,30 @@
<template>
<div ref="root" class="ph-no-capture" data-test-id="inline-expression-editor-output"></div>
<div :class="visible ? $style.dropdown : $style.hidden">
<n8n-text size="small" compact :class="$style.header">
{{ $locale.baseText('parameterInput.resultForItem') }} {{ hoveringItemNumber }}
</n8n-text>
<n8n-text :class="$style.body">
<div ref="root" class="ph-no-capture" data-test-id="inline-expression-editor-output"></div>
</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>
</template>
<script lang="ts">
@@ -13,12 +38,29 @@ import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { outputTheme } from './theme';
import type { Plaintext, Resolved, Segment } from '@/types/expressions';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
export default defineComponent({
name: 'InlineExpressionEditorOutput',
props: {
segments: {
type: Array as PropType<Segment[]>,
required: true,
},
value: {
type: String,
},
isReadOnly: {
type: Boolean,
default: false,
},
visible: {
type: Boolean,
default: false,
},
hoveringItemNumber: {
type: Number,
required: true,
},
},
watch: {
@@ -36,6 +78,7 @@ export default defineComponent({
data() {
return {
editor: null as EditorView | null,
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
};
},
mounted() {
@@ -87,4 +130,64 @@ export default defineComponent({
});
</script>
<style lang="scss"></style>
<style lang="scss" module>
.hidden {
display: none;
}
.dropdown {
display: flex;
flex-direction: column;
position: absolute;
z-index: 2; // cover tooltips
background: white;
border: var(--border-base);
border-top: none;
width: 100%;
box-shadow: 0 2px 6px 0 rgba(#441c17, 0.1);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
.header,
.body,
.footer {
padding: var(--spacing-3xs);
}
.header {
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
padding-left: var(--spacing-2xs);
padding-top: var(--spacing-2xs);
}
.body {
padding-top: 0;
padding-left: var(--spacing-2xs);
color: var(--color-text-dark);
}
.footer {
border-top: var(--border-base);
padding: var(--spacing-4xs);
padding-left: var(--spacing-2xs);
padding-top: 0;
line-height: var(--font-line-height-regular);
color: var(--color-text-base);
.expression-syntax-example {
display: inline-block;
font-size: var(--font-size-2xs);
height: var(--font-size-m);
background-color: #f0f0f0;
margin-left: var(--spacing-5xs);
margin-right: var(--spacing-5xs);
}
.learn-more {
line-height: 1;
white-space: nowrap;
}
}
}
</style>

View File

@@ -107,7 +107,7 @@ export default defineComponent({
return false;
}
if (this.parameter.typeOptions?.editor === 'codeNodeEditor') {
if (['codeNodeEditor', 'sqlEditor'].includes(this.parameter.typeOptions?.editor)) {
return false;
}

View File

@@ -1,17 +1,23 @@
<template>
<div ref="sqlEditor" class="ph-no-capture"></div>
<div :class="$style.sqlEditor" v-click-outside="onBlur">
<div ref="sqlEditor" data-test-id="sql-editor-container" class="ph-no-capture"></div>
<InlineExpressionEditorOutput
:segments="segments"
:value="query"
:isReadOnly="isReadOnly"
:visible="isFocused"
:hoveringItemNumber="hoveringItemNumber"
/>
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { autocompletion } from '@codemirror/autocomplete';
import { indentWithTab, history, redo } from '@codemirror/commands';
import { foldGutter, indentOnInput } from '@codemirror/language';
import { lintGutter } from '@codemirror/lint';
import type { Extension } from '@codemirror/state';
import { acceptCompletion, autocompletion, ifNotIn } from '@codemirror/autocomplete';
import { indentWithTab, history, redo, toggleComment } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput, LanguageSupport } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
import type { ViewUpdate } from '@codemirror/view';
import type { Extension } from '@codemirror/state';
import {
dropCursor,
EditorView,
@@ -20,76 +26,189 @@ import {
keymap,
lineNumbers,
} from '@codemirror/view';
import { MSSQL, MySQL, PostgreSQL, sql, StandardSQL } from '@codemirror/lang-sql';
import type { SQLDialect } from 'n8n-workflow';
import {
MSSQL,
MySQL,
PostgreSQL,
StandardSQL,
MariaSQL,
SQLite,
Cassandra,
PLSQL,
keywordCompletionSource,
} from '@n8n/codemirror-lang-sql';
import type { SQLDialect as SQLDialectType } from '@n8n/codemirror-lang-sql';
import { codeNodeEditorTheme } from '../CodeNodeEditor/theme';
import { n8nCompletionSources } from '@/plugins/codemirror/completions/addCompletions';
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
import { expressionManager } from '@/mixins/expressionManager';
import InlineExpressionEditorOutput from '@/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue';
import { EXPRESSIONS_DOCS_URL } from '@/constants';
import { codeNodeEditorEventBus } from '@/event-bus';
const SQL_DIALECTS = {
standard: StandardSQL,
mssql: MSSQL,
mysql: MySQL,
postgres: PostgreSQL,
StandardSQL,
PostgreSQL,
MySQL,
MariaSQL,
MSSQL,
SQLite,
Cassandra,
PLSQL,
} as const;
type SQLEditorData = {
editor: EditorView | null;
isFocused: boolean;
skipSegments: string[];
expressionsDocsUrl: string;
};
export default defineComponent({
name: 'sql-editor',
components: {
InlineExpressionEditorOutput,
},
mixins: [expressionManager],
props: {
query: {
type: String,
required: true,
},
dialect: {
type: String as PropType<SQLDialect>,
default: 'standard',
type: String,
default: 'StandardSQL',
validator: (value: string) => {
return Object.keys(SQL_DIALECTS).includes(value);
},
},
isReadOnly: {
type: Boolean,
default: false,
},
},
data() {
data(): SQLEditorData {
return {
editor: {} as EditorView,
editor: null,
expressionsDocsUrl: EXPRESSIONS_DOCS_URL,
isFocused: false,
skipSegments: ['Statement', 'CompositeIdentifier', 'Parens'],
};
},
watch: {
'ndvStore.ndvInputData'() {
this.editor?.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: this.query,
},
});
setTimeout(() => {
this.editor?.contentDOM.blur();
});
},
},
computed: {
doc(): string {
return this.editor.state.doc.toString();
return this.editor?.state.doc.toString() ?? '';
},
hoveringItemNumber(): number {
return this.ndvStore.hoveringItemNumber;
},
sqlDialect(): SQLDialectType {
return SQL_DIALECTS[this.dialect as keyof typeof SQL_DIALECTS] ?? SQL_DIALECTS.StandardSQL;
},
extensions(): Extension[] {
const dialect = this.sqlDialect;
function sqlWithN8nLanguageSupport() {
return new LanguageSupport(dialect.language, [
dialect.language.data.of({
autocomplete: ifNotIn(['Resolvable'], keywordCompletionSource(dialect, true)),
}),
n8nCompletionSources().map((source) => dialect.language.data.of(source)),
]);
}
const extensions = [
sqlWithN8nLanguageSupport(),
expressionInputHandler(),
codeNodeEditorTheme({ isReadOnly: this.isReadOnly, customMaxHeight: '350px' }),
lineNumbers(),
EditorView.lineWrapping,
EditorState.readOnly.of(this.isReadOnly),
EditorView.domEventHandlers({
focus: () => {
this.isFocused = true;
},
}),
EditorState.readOnly.of(this.isReadOnly),
EditorView.editable.of(!this.isReadOnly),
];
if (!this.isReadOnly) {
extensions.push(
history(),
keymap.of([
{ key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-/', run: toggleComment },
{ key: 'Tab', run: acceptCompletion },
indentWithTab,
]),
autocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),
foldGutter(),
dropCursor(),
bracketMatching(),
EditorView.updateListener.of((viewUpdate) => {
if (!viewUpdate.docChanged || !this.editor) return;
highlighter.removeColor(this.editor as EditorView, this.plaintextSegments);
highlighter.addColor(this.editor as EditorView, this.resolvableSegments);
this.$emit('valueChanged', this.doc);
}),
);
}
return extensions;
},
},
mounted() {
const dialect = SQL_DIALECTS[this.dialect as SQLDialect] ?? SQL_DIALECTS.standard;
const extensions: Extension[] = [
sql({ dialect, upperCaseKeywords: true }),
codeNodeEditorTheme({ maxHeight: false }),
lineNumbers(),
EditorView.lineWrapping,
lintGutter(),
EditorState.readOnly.of(this.isReadOnly),
];
if (!this.isReadOnly) codeNodeEditorEventBus.on('error-line-number', this.highlightLine);
if (this.isReadOnly) {
extensions.push(EditorView.editable.of(this.isReadOnly));
} else {
extensions.push(
history(),
keymap.of([indentWithTab, { key: 'Mod-Shift-z', run: redo }]),
autocompletion(),
indentOnInput(),
highlightActiveLine(),
highlightActiveLineGutter(),
foldGutter(),
dropCursor(),
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
if (!viewUpdate.docChanged) return;
this.$emit('valueChanged', this.doc);
}),
);
}
const state = EditorState.create({ doc: this.query, extensions });
const state = EditorState.create({ doc: this.query, extensions: this.extensions });
this.editor = new EditorView({ parent: this.$refs.sqlEditor as HTMLDivElement, state });
highlighter.addColor(this.editor as EditorView, this.resolvableSegments);
},
methods: {
onBlur() {
this.isFocused = false;
},
highlightLine(line: number | 'final') {
if (!this.editor) return;
if (line === 'final') {
this.editor.dispatch({
selection: { anchor: this.query.length },
});
return;
}
this.editor.dispatch({
selection: { anchor: this.editor.state.doc.line(line).from },
});
},
},
});
</script>
<style module lang="scss">
.sqlEditor {
position: relative;
}
</style>

View File

@@ -0,0 +1,112 @@
import { render } from '@testing-library/vue';
import { PiniaVuePlugin } from 'pinia';
import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
import { STORES } from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import SqlEditor from '@/components/SqlEditor/SqlEditor.vue';
import { expressionManager } from '@/mixins/expressionManager';
import type { TargetItem } from '@/Interface';
const EXPRESSION_OUTPUT_TEST_ID = 'inline-expression-editor-output';
const RESOLVABLES: { [key: string]: string | number | boolean } = {
'{{ $json.schema }}': 'public',
'{{ $json.table }}': 'users',
'{{ $json.id }}': 'id',
'{{ $json.limit - 10 }}': 0,
'{{ $json.active }}': false,
};
const DEFAULT_SETUP = {
pinia: createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: SETTINGS_STORE_DEFAULT_STATE.settings,
},
},
}),
props: {
dialect: 'PostgreSQL',
isReadOnly: false,
},
};
const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) =>
render(SqlEditor, { ...DEFAULT_SETUP, ...renderOptions }, (vue) => {
vue.use(PiniaVuePlugin);
});
describe('SQL Editor Preview Tests', () => {
beforeEach(() => {
vi.spyOn(expressionManager.methods, 'resolve').mockImplementation(
(resolvable: string, _targetItem?: TargetItem) => {
return { resolved: RESOLVABLES[resolvable] };
},
);
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders basic query', async () => {
const { getByTestId } = renderComponent({
props: {
query: 'SELECT * FROM users',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users');
});
it('renders basic query with expression', async () => {
const { getByTestId } = renderComponent({
props: {
query: 'SELECT * FROM {{ $json.table }}',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM users');
});
it('renders resolved expressions with dot between resolvables', async () => {
const { getByTestId } = renderComponent({
props: {
query: 'SELECT * FROM {{ $json.schema }}.{{ $json.table }}',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent('SELECT * FROM public.users');
});
it('renders resolved expressions which resolve to 0', async () => {
const { getByTestId } = renderComponent({
props: {
query:
'SELECT * FROM {{ $json.schema }}.{{ $json.table }} WHERE {{ $json.id }} > {{ $json.limit - 10 }}',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users WHERE id > 0',
);
});
it('keeps query formatting in rendered output', async () => {
const { getByTestId } = renderComponent({
props: {
query:
'SELECT * FROM {{ $json.schema }}.{{ $json.table }}\n WHERE id > {{ $json.limit - 10 }}\n AND active = {{ $json.active }};',
},
});
await waitAllPromises();
expect(getByTestId(EXPRESSION_OUTPUT_TEST_ID)).toHaveTextContent(
'SELECT * FROM public.users WHERE id > 0 AND active = false;',
);
// Output should have the same number of lines as the input
expect(getByTestId('sql-editor-container').getElementsByClassName('cm-line').length).toEqual(
getByTestId(EXPRESSION_OUTPUT_TEST_ID).getElementsByClassName('cm-line').length,
);
});
});