From 49dcb7f6892f55bf7320f9a0075b40becfd0a656 Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Fri, 20 Dec 2019 15:35:43 +0200 Subject: [PATCH] Refactor QueryEditor component (#4464) --- client/app/assets/less/inc/ace-editor.less | 24 +- client/app/assets/less/inc/base.less | 19 - client/app/assets/less/redash/query.less | 20 - client/app/components/QueryEditor.css | 3 - client/app/components/QueryEditor.jsx | 347 ------------------ client/app/components/keywordBuilder.js | 50 --- .../QueryEditor}/AutocompleteToggle.jsx | 29 +- .../QueryEditor/QueryEditorComponent.jsx | 178 +++++++++ .../QueryEditor/QueryEditorControls.jsx | 138 +++++++ .../QueryEditor/QueryEditorControls.less | 23 ++ .../app/components/queries/QueryEditor/ace.js | 109 ++++++ .../components/queries/QueryEditor/index.jsx | 181 +++++++++ .../components/queries/QueryEditor/index.less | 36 ++ client/app/pages/queries/query.html | 4 +- client/app/pages/queries/source-view.js | 1 - client/app/pages/queries/view.js | 5 + 16 files changed, 705 insertions(+), 462 deletions(-) delete mode 100644 client/app/components/QueryEditor.css delete mode 100644 client/app/components/QueryEditor.jsx delete mode 100644 client/app/components/keywordBuilder.js rename client/app/components/{ => queries/QueryEditor}/AutocompleteToggle.jsx (58%) create mode 100644 client/app/components/queries/QueryEditor/QueryEditorComponent.jsx create mode 100644 client/app/components/queries/QueryEditor/QueryEditorControls.jsx create mode 100644 client/app/components/queries/QueryEditor/QueryEditorControls.less create mode 100644 client/app/components/queries/QueryEditor/ace.js create mode 100644 client/app/components/queries/QueryEditor/index.jsx create mode 100644 client/app/components/queries/QueryEditor/index.less diff --git a/client/app/assets/less/inc/ace-editor.less b/client/app/assets/less/inc/ace-editor.less index c59a65e5b..bc92a2c96 100644 --- a/client/app/assets/less/inc/ace-editor.less +++ b/client/app/assets/less/inc/ace-editor.less @@ -1,7 +1,25 @@ .ace_editor { - border: 1px solid #eee; + border: 1px solid fade(@redash-gray, 15%); height: 100%; margin-bottom: 10px; + + &.ace_autocomplete .ace_completion-highlight { + text-shadow: none !important; + background: #ffff005e; + font-weight: 600; + } + + &.ace-tm { + .ace_gutter { + background: #fff !important; + } + + .ace_gutter-active-line { + background-color: fade(@redash-gray, 20%) !important; + } + + .ace_marker-layer .ace_active-line { + background: fade(@redash-gray, 9%) !important; + } + } } - - diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index b9e3cda7e..093e85468 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -131,25 +131,6 @@ strong { transition: height 0s, width 0s !important; } -// Ace Editor -.ace_editor { - border: 1px solid fade(@redash-gray, 15%) !important; -} - -.ace-tm { - .ace_gutter { - background: #fff !important; - } - - .ace_gutter-active-line { - background-color: fade(@redash-gray, 20%) !important; - } - - .ace_marker-layer .ace_active-line { - background: fade(@redash-gray, 9%) !important; - } -} - .bg-ace { background-color: fade(@redash-gray, 12%) !important; } diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less index 92b24468e..91a2b161f 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -84,24 +84,10 @@ edit-in-place p.editable:hover { display: inline-block; } -.editor__control { - margin-top: 10px; - - .dropdown-toggle { - margin-right: 0; - } -} - .filter-container { margin-bottom: 5px; } -.ace_editor.ace_autocomplete .ace_completion-highlight { - text-shadow: none !important; - background: #ffff005e; - font-weight: 600; -} - .query-metadata { background: #fff; overflow: hidden; @@ -144,12 +130,6 @@ edit-in-place p.editable:hover { } } -.editor__control { - .form-control { - height: 30px; - } -} - .schema-container { background: transparent; flex-grow: 1; diff --git a/client/app/components/QueryEditor.css b/client/app/components/QueryEditor.css deleted file mode 100644 index ca718e1fd..000000000 --- a/client/app/components/QueryEditor.css +++ /dev/null @@ -1,3 +0,0 @@ -.editor__container[data-executing="true"] .ace_marker-layer .ace_selection { - background-color: rgb(255, 210, 181); -} diff --git a/client/app/components/QueryEditor.jsx b/client/app/components/QueryEditor.jsx deleted file mode 100644 index a5ad76fce..000000000 --- a/client/app/components/QueryEditor.jsx +++ /dev/null @@ -1,347 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import Tooltip from "antd/lib/tooltip"; -import { react2angular } from "react2angular"; - -import AceEditor from "react-ace"; -import ace from "brace"; -import notification from "@/services/notification"; - -import "brace/ext/language_tools"; -import "brace/mode/json"; -import "brace/mode/python"; -import "brace/mode/sql"; -import "brace/mode/yaml"; -import "brace/theme/textmate"; -import "brace/ext/searchbox"; - -import { Query } from "@/services/query"; -import { QuerySnippet } from "@/services/query-snippet"; -import { KeyboardShortcuts } from "@/services/keyboard-shortcuts"; - -import localOptions from "@/lib/localOptions"; -import AutocompleteToggle from "@/components/AutocompleteToggle"; -import keywordBuilder from "./keywordBuilder"; -import { DataSource, Schema } from "./proptypes"; - -import "./QueryEditor.css"; - -const langTools = ace.acequire("ace/ext/language_tools"); -const snippetsModule = ace.acequire("ace/snippets"); - -// By default Ace will try to load snippet files for the different modes and fail. -// We don't need them, so we use these placeholders until we define our own. -function defineDummySnippets(mode) { - ace.define(`ace/snippets/${mode}`, ["require", "exports", "module"], (require, exports) => { - exports.snippetText = ""; - exports.scope = mode; - }); -} - -defineDummySnippets("python"); -defineDummySnippets("sql"); -defineDummySnippets("json"); -defineDummySnippets("yaml"); - -class QueryEditor extends React.Component { - static propTypes = { - queryText: PropTypes.string.isRequired, - schema: Schema, // eslint-disable-line react/no-unused-prop-types - addNewParameter: PropTypes.func.isRequired, - dataSources: PropTypes.arrayOf(DataSource), - dataSource: DataSource, - canEdit: PropTypes.bool.isRequired, - isDirty: PropTypes.bool.isRequired, - isQueryOwner: PropTypes.bool.isRequired, - updateDataSource: PropTypes.func.isRequired, - canExecuteQuery: PropTypes.bool.isRequired, - executeQuery: PropTypes.func.isRequired, - queryExecuting: PropTypes.bool.isRequired, - saveQuery: PropTypes.func.isRequired, - updateQuery: PropTypes.func.isRequired, - updateSelectedQuery: PropTypes.func.isRequired, - listenForResize: PropTypes.func.isRequired, - listenForEditorCommand: PropTypes.func.isRequired, - }; - - static defaultProps = { - schema: null, - dataSource: {}, - dataSources: [], - }; - - constructor(props) { - super(props); - - this.refEditor = React.createRef(); - - this.state = { - schema: null, // eslint-disable-line react/no-unused-state - keywords: { - table: [], - column: [], - tableColumn: [], - }, - autocompleteQuery: localOptions.get("liveAutocomplete", true), - liveAutocompleteDisabled: false, - // XXX temporary while interfacing with angular - queryText: props.queryText, - selectedQueryText: null, - }; - - const schemaCompleter = { - identifierRegexps: [/[a-zA-Z_0-9.\-\u00A2-\uFFFF]/], - getCompletions: (state, session, pos, prefix, callback) => { - const tableKeywords = this.state.keywords.table; - const columnKeywords = this.state.keywords.column; - const tableColumnKeywords = this.state.keywords.tableColumn; - - if (prefix.length === 0 || tableKeywords.length === 0) { - callback(null, []); - return; - } - - if (prefix[prefix.length - 1] === ".") { - const tableName = prefix.substring(0, prefix.length - 1); - callback(null, tableKeywords.concat(tableColumnKeywords[tableName])); - return; - } - callback(null, tableKeywords.concat(columnKeywords)); - }, - }; - - langTools.setCompleters([ - langTools.snippetCompleter, - langTools.keyWordCompleter, - langTools.textCompleter, - schemaCompleter, - ]); - } - - static getDerivedStateFromProps(nextProps, prevState) { - if (!nextProps.schema) { - return { - keywords: { - table: [], - column: [], - tableColumn: [], - }, - liveAutocompleteDisabled: false, - }; - } else if (nextProps.schema !== prevState.schema) { - const tokensCount = nextProps.schema.reduce((totalLength, table) => totalLength + table.columns.length, 0); - return { - schema: nextProps.schema, - keywords: keywordBuilder.buildKeywordsFromSchema(nextProps.schema), - liveAutocompleteDisabled: tokensCount > 5000, - }; - } - return null; - } - - onLoad = editor => { - // Release Cmd/Ctrl+L to the browser - editor.commands.bindKey("Cmd+L", null); - editor.commands.bindKey("Ctrl+P", null); - editor.commands.bindKey("Ctrl+L", null); - - // Ignore Ctrl+P to open new parameter dialog - editor.commands.bindKey({ win: "Ctrl+P", mac: null }, null); - // Lineup only mac - editor.commands.bindKey({ win: null, mac: "Ctrl+P" }, "golineup"); - editor.commands.bindKey({ win: "Ctrl+Shift+F", mac: "Cmd+Shift+F" }, this.formatQuery); - - // Reset Completer in case dot is pressed - editor.commands.on("afterExec", e => { - if (e.command.name === "insertstring" && e.args === "." && editor.completer) { - editor.completer.showPopup(editor); - } - }); - - QuerySnippet.query(snippets => { - const snippetManager = snippetsModule.snippetManager; - const m = { - snippetText: "", - }; - m.snippets = snippetManager.parseSnippetFile(m.snippetText); - snippets.forEach(snippet => { - m.snippets.push(snippet.getSnippet()); - }); - snippetManager.register(m.snippets || [], m.scope); - }); - - editor.focus(); - this.props.listenForResize(() => editor.resize()); - this.props.listenForEditorCommand((e, command, ...args) => { - switch (command) { - case "focus": { - editor.focus(); - break; - } - case "paste": { - const [text] = args; - editor.session.doc.replace(editor.selection.getRange(), text); - const range = editor.selection.getRange(); - this.props.updateQuery(editor.session.getValue()); - editor.selection.setRange(range); - break; - } - default: - break; - } - }); - }; - - updateSelectedQuery = selection => { - const { editor } = this.refEditor.current; - const doc = editor.getSession().doc; - const rawSelectedQueryText = doc.getTextRange(selection.getRange()); - const selectedQueryText = rawSelectedQueryText.length > 1 ? rawSelectedQueryText : null; - this.setState({ selectedQueryText }); - this.props.updateSelectedQuery(selectedQueryText); - }; - - updateQuery = queryText => { - this.props.updateQuery(queryText); - this.setState({ queryText }); - }; - - formatQuery = () => { - Query.format(this.props.dataSource.syntax || "sql", this.props.queryText) - .then(this.updateQuery) - .catch(error => notification.error(error)); - }; - - toggleAutocomplete = state => { - this.setState({ autocompleteQuery: state }); - localOptions.set("liveAutocomplete", state); - }; - - componentDidUpdate = () => { - // ANGULAR_REMOVE_ME Work-around for a resizing issue, see https://github.com/getredash/redash/issues/3353 - const { editor } = this.refEditor.current; - editor.resize(); - }; - - render() { - const modKey = KeyboardShortcuts.modKey; - - const isExecuteDisabled = this.props.queryExecuting || !this.props.canExecuteQuery; - - return ( -
-
-
- -
- -
-
- - Add New Parameter ({modKey} + P) - - }> - - - - Format Query ({modKey} + Shift + F) - - }> - - - - - {this.props.canEdit ? ( - - - - ) : null} - - {/* - Tooltip wraps disabled buttons with `` and moves all styles - and classes to that ``. There is a piece of CSS that fixes - button appearance, but also wwe need to add `disabled` class to - disabled buttons so it will be assigned to wrapper and make it - looking properly - */} - - -
-
-
-
- ); - } -} - -export default function init(ngModule) { - ngModule.component("queryEditor", react2angular(QueryEditor)); -} - -init.init = true; diff --git a/client/app/components/keywordBuilder.js b/client/app/components/keywordBuilder.js deleted file mode 100644 index 3265c6fa4..000000000 --- a/client/app/components/keywordBuilder.js +++ /dev/null @@ -1,50 +0,0 @@ -import { map } from "lodash"; - -function buildTableColumnKeywords(table) { - const keywords = []; - table.columns.forEach(column => { - keywords.push({ - caption: column, - name: `${table.name}.${column}`, - value: `${table.name}.${column}`, - score: 100, - meta: "Column", - className: "completion", - }); - }); - return keywords; -} - -function buildKeywordsFromSchema(schema) { - const tableKeywords = []; - const columnKeywords = {}; - const tableColumnKeywords = {}; - - schema.forEach(table => { - tableKeywords.push({ - name: table.name, - value: table.name, - score: 100, - meta: "Table", - }); - tableColumnKeywords[table.name] = buildTableColumnKeywords(table); - table.columns.forEach(c => { - columnKeywords[c] = "Column"; - }); - }); - - return { - table: tableKeywords, - column: map(columnKeywords, (v, k) => ({ - name: k, - value: k, - score: 50, - meta: v, - })), - tableColumn: tableColumnKeywords, - }; -} - -export default { - buildKeywordsFromSchema, -}; diff --git a/client/app/components/AutocompleteToggle.jsx b/client/app/components/queries/QueryEditor/AutocompleteToggle.jsx similarity index 58% rename from client/app/components/AutocompleteToggle.jsx rename to client/app/components/queries/QueryEditor/AutocompleteToggle.jsx index 7698157f8..37742b86e 100644 --- a/client/app/components/AutocompleteToggle.jsx +++ b/client/app/components/queries/QueryEditor/AutocompleteToggle.jsx @@ -1,42 +1,39 @@ -import React from "react"; +import React, { useCallback } from "react"; import Tooltip from "antd/lib/tooltip"; +import Button from "antd/lib/button"; import PropTypes from "prop-types"; import "@/redash-font/style.less"; import recordEvent from "@/services/recordEvent"; -export default function AutocompleteToggle({ state, disabled, onToggle }) { +export default function AutocompleteToggle({ available, enabled, onToggle }) { let tooltipMessage = "Live Autocomplete Enabled"; let icon = "icon-flash"; - if (!state) { + if (!enabled) { tooltipMessage = "Live Autocomplete Disabled"; icon = "icon-flash-off"; } - if (disabled) { + if (!available) { tooltipMessage = "Live Autocomplete Not Available (Use Ctrl+Space to Trigger)"; icon = "icon-flash-off"; } - const toggle = newState => { - recordEvent("toggle_autocomplete", "screen", "query_editor", { state: newState }); - onToggle(newState); - }; + const handleClick = useCallback(() => { + recordEvent("toggle_autocomplete", "screen", "query_editor", { state: !enabled }); + onToggle(!enabled); + }, [enabled, onToggle]); return ( - + ); } AutocompleteToggle.propTypes = { - state: PropTypes.bool.isRequired, - disabled: PropTypes.bool.isRequired, + available: PropTypes.bool.isRequired, + enabled: PropTypes.bool.isRequired, onToggle: PropTypes.func.isRequired, }; diff --git a/client/app/components/queries/QueryEditor/QueryEditorComponent.jsx b/client/app/components/queries/QueryEditor/QueryEditorComponent.jsx new file mode 100644 index 000000000..c79ce8bc9 --- /dev/null +++ b/client/app/components/queries/QueryEditor/QueryEditorComponent.jsx @@ -0,0 +1,178 @@ +import React, { useEffect, useMemo, useRef, useState, useCallback, useImperativeHandle } from "react"; +import PropTypes from "prop-types"; +import cx from "classnames"; +import { AceEditor, snippetsModule, updateSchemaCompleter } from "./ace"; +import resizeObserver from "@/services/resizeObserver"; +import { QuerySnippet } from "@/services/query-snippet"; + +const editorProps = { $blockScrolling: Infinity }; + +const QueryEditorComponent = React.forwardRef(function( + { className, syntax, value, autocompleteEnabled, schema, onChange, onSelectionChange, ...props }, + ref +) { + const [container, setContainer] = useState(null); + const editorRef = useRef(null); + + // For some reason, value for AceEditor should be managed in this way - otherwise it goes berserk when selecting text + const [currentValue, setCurrentValue] = useState(value); + + useEffect(() => { + setCurrentValue(value); + }, [value]); + + const handleChange = useCallback( + str => { + setCurrentValue(str); + onChange(str); + }, + [onChange] + ); + + const editorOptions = useMemo( + () => ({ + behavioursEnabled: true, + enableSnippets: true, + enableBasicAutocompletion: true, + enableLiveAutocompletion: autocompleteEnabled, + autoScrollEditorIntoView: true, + }), + [autocompleteEnabled] + ); + + useEffect(() => { + if (editorRef.current) { + const { editor } = editorRef.current; + updateSchemaCompleter(editor.id, schema); // TODO: cleanup? + } + }, [schema]); + + useEffect(() => { + function resize() { + if (editorRef.current) { + const { editor } = editorRef.current; + editor.resize(); + } + } + + if (container) { + resize(); + const unwatch = resizeObserver(container, resize); + return unwatch; + } + }, [container]); + + const handleSelectionChange = useCallback( + selection => { + const { editor } = editorRef.current; + const rawSelectedQueryText = editor.session.doc.getTextRange(selection.getRange()); + const selectedQueryText = rawSelectedQueryText.length > 1 ? rawSelectedQueryText : null; + onSelectionChange(selectedQueryText); + }, + [onSelectionChange] + ); + + const initEditor = useCallback(editor => { + // Release Cmd/Ctrl+L to the browser + editor.commands.bindKey("Cmd+L", null); + editor.commands.bindKey("Ctrl+P", null); + editor.commands.bindKey("Ctrl+L", null); + + // Ignore Ctrl+P to open new parameter dialog + editor.commands.bindKey({ win: "Ctrl+P", mac: null }, null); + // Lineup only mac + editor.commands.bindKey({ win: null, mac: "Ctrl+P" }, "golineup"); + editor.commands.bindKey({ win: "Ctrl+Shift+F", mac: "Cmd+Shift+F" }, () => console.log("formatQuery")); + + // Reset Completer in case dot is pressed + editor.commands.on("afterExec", e => { + if (e.command.name === "insertstring" && e.args === "." && editor.completer) { + editor.completer.showPopup(editor); + } + }); + + QuerySnippet.query(snippets => { + const snippetManager = snippetsModule.snippetManager; + const m = { + snippetText: "", + }; + m.snippets = snippetManager.parseSnippetFile(m.snippetText); + snippets.forEach(snippet => { + m.snippets.push(snippet.getSnippet()); + }); + snippetManager.register(m.snippets || [], m.scope); + }); + + editor.focus(); + }, []); + + useImperativeHandle( + ref, + () => ({ + paste: text => { + if (editorRef.current) { + const { editor } = editorRef.current; + editor.session.doc.replace(editor.selection.getRange(), text); + const range = editor.selection.getRange(); + onChange(editor.session.getValue()); + editor.selection.setRange(range); + } + }, + focus: () => { + if (editorRef.current) { + const { editor } = editorRef.current; + editor.focus(); + } + }, + }), + [onChange] + ); + + return ( +
+ +
+ ); +}); + +QueryEditorComponent.propTypes = { + className: PropTypes.string, + syntax: PropTypes.string, + value: PropTypes.string, + autocompleteEnabled: PropTypes.bool, + schema: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + size: PropTypes.number, + columns: PropTypes.arrayOf(PropTypes.string).isRequired, + }) + ), + onChange: PropTypes.func, + onSelectionChange: PropTypes.func, +}; + +QueryEditorComponent.defaultProps = { + className: null, + syntax: null, + value: null, + autocompleteEnabled: true, + schema: [], + onChange: () => {}, + onSelectionChange: () => {}, +}; + +export default QueryEditorComponent; diff --git a/client/app/components/queries/QueryEditor/QueryEditorControls.jsx b/client/app/components/queries/QueryEditor/QueryEditorControls.jsx new file mode 100644 index 000000000..855a3d886 --- /dev/null +++ b/client/app/components/queries/QueryEditor/QueryEditorControls.jsx @@ -0,0 +1,138 @@ +import { map } from "lodash"; +import React from "react"; +import PropTypes from "prop-types"; +import Tooltip from "antd/lib/tooltip"; +import Button from "antd/lib/button"; +import Select from "antd/lib/select"; + +import AutocompleteToggle from "./AutocompleteToggle"; +import "./QueryEditorControls.less"; + +export default function EditorControl({ + addParameterButtonProps, + formatButtonProps, + saveButtonProps, + executeButtonProps, + autocompleteToggleProps, + dataSourceSelectorProps, +}) { + return ( +
+ {addParameterButtonProps !== false && ( + + + + )} + {formatButtonProps !== false && ( + + + + )} + {autocompleteToggleProps !== false && ( + + )} + {dataSourceSelectorProps === false && } + {dataSourceSelectorProps !== false && ( + + )} + {saveButtonProps !== false && ( + + + + )} + {executeButtonProps !== false && ( + + + + )} +
+ ); +} + +const ButtonPropsPropType = PropTypes.oneOfType([ + PropTypes.bool, // `false` to hide button + PropTypes.shape({ + title: PropTypes.node, + disabled: PropTypes.bool, + onClick: PropTypes.func, + text: PropTypes.node, + }), +]); + +EditorControl.propTypes = { + addParameterButtonProps: ButtonPropsPropType, + formatButtonProps: ButtonPropsPropType, + saveButtonProps: ButtonPropsPropType, + executeButtonProps: ButtonPropsPropType, + autocompleteToggleProps: PropTypes.oneOfType([ + PropTypes.bool, // `false` to hide + PropTypes.shape({ + available: PropTypes.bool, + enabled: PropTypes.bool, + onToggle: PropTypes.func, + }), + ]), + dataSourceSelectorProps: PropTypes.oneOfType([ + PropTypes.bool, // `false` to hide + PropTypes.shape({ + disabled: PropTypes.bool, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + label: PropTypes.node, + }) + ), + onChange: PropTypes.func, + }), + ]), +}; + +EditorControl.defaultProps = { + addParameterButtonProps: false, + formatButtonProps: false, + saveButtonProps: false, + executeButtonProps: false, + autocompleteToggleProps: false, + dataSourceSelectorProps: false, +}; diff --git a/client/app/components/queries/QueryEditor/QueryEditorControls.less b/client/app/components/queries/QueryEditor/QueryEditorControls.less new file mode 100644 index 000000000..c80e75dc1 --- /dev/null +++ b/client/app/components/queries/QueryEditor/QueryEditorControls.less @@ -0,0 +1,23 @@ +.query-editor-controls { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + justify-content: stretch; + + // Styles for a wrapper that `Tooltip` adds for disabled `Button`s + span.query-editor-controls-button { + display: flex !important; + align-items: stretch; + justify-content: stretch; + } + + .ant-btn { + height: auto; + + .fa + span, + .zmdi + span { + // if button has icon and label - add some space between them + margin-left: 5px; + } + } +} diff --git a/client/app/components/queries/QueryEditor/ace.js b/client/app/components/queries/QueryEditor/ace.js new file mode 100644 index 000000000..b4c468994 --- /dev/null +++ b/client/app/components/queries/QueryEditor/ace.js @@ -0,0 +1,109 @@ +import { isNil, map } from "lodash"; +import AceEditor from "react-ace"; +import ace from "brace"; + +import "brace/ext/language_tools"; +import "brace/mode/json"; +import "brace/mode/python"; +import "brace/mode/sql"; +import "brace/mode/yaml"; +import "brace/theme/textmate"; +import "brace/ext/searchbox"; + +const langTools = ace.acequire("ace/ext/language_tools"); +const snippetsModule = ace.acequire("ace/snippets"); + +// By default Ace will try to load snippet files for the different modes and fail. +// We don't need them, so we use these placeholders until we define our own. +function defineDummySnippets(mode) { + ace.define(`ace/snippets/${mode}`, ["require", "exports", "module"], (require, exports) => { + exports.snippetText = ""; + exports.scope = mode; + }); +} + +defineDummySnippets("python"); +defineDummySnippets("sql"); +defineDummySnippets("json"); +defineDummySnippets("yaml"); + +function buildTableColumnKeywords(table) { + const keywords = []; + table.columns.forEach(column => { + keywords.push({ + caption: column, + name: `${table.name}.${column}`, + value: `${table.name}.${column}`, + score: 100, + meta: "Column", + className: "completion", + }); + }); + return keywords; +} + +function buildKeywordsFromSchema(schema) { + const tableKeywords = []; + const columnKeywords = {}; + const tableColumnKeywords = {}; + + schema.forEach(table => { + tableKeywords.push({ + name: table.name, + value: table.name, + score: 100, + meta: "Table", + }); + tableColumnKeywords[table.name] = buildTableColumnKeywords(table); + table.columns.forEach(c => { + columnKeywords[c] = "Column"; + }); + }); + + return { + table: tableKeywords, + column: map(columnKeywords, (v, k) => ({ + name: k, + value: k, + score: 50, + meta: v, + })), + tableColumn: tableColumnKeywords, + }; +} + +const schemaCompleterKeywords = {}; + +export function updateSchemaCompleter(editorKey, schema = null) { + schemaCompleterKeywords[editorKey] = isNil(schema) ? null : buildKeywordsFromSchema(schema); +} + +langTools.setCompleters([ + langTools.snippetCompleter, + langTools.keyWordCompleter, + langTools.textCompleter, + { + identifierRegexps: [/[a-zA-Z_0-9.\-\u00A2-\uFFFF]/], + getCompletions: (editor, session, pos, prefix, callback) => { + const { table, column, tableColumn } = schemaCompleterKeywords[editor.id] || { + table: [], + column: [], + tableColumn: [], + }; + + if (prefix.length === 0 || table.length === 0) { + callback(null, []); + return; + } + + if (prefix[prefix.length - 1] === ".") { + const tableName = prefix.substring(0, prefix.length - 1); + callback(null, table.concat(tableColumn[tableName])); + return; + } + callback(null, table.concat(column)); + }, + }, +]); + +export { AceEditor, langTools, snippetsModule }; diff --git a/client/app/components/queries/QueryEditor/index.jsx b/client/app/components/queries/QueryEditor/index.jsx new file mode 100644 index 000000000..6be0a9039 --- /dev/null +++ b/client/app/components/queries/QueryEditor/index.jsx @@ -0,0 +1,181 @@ +import { map, reduce } from "lodash"; +import React, { useRef, useState, useMemo, useEffect, useCallback } from "react"; +import PropTypes from "prop-types"; +import { react2angular } from "react2angular"; +import { DataSource, Schema } from "@/components/proptypes"; +import { Query } from "@/services/query"; +import { KeyboardShortcuts } from "@/services/keyboard-shortcuts"; +import { $rootScope } from "@/services/ng"; +import notification from "@/services/notification"; +import localOptions from "@/lib/localOptions"; + +import QueryEditorComponent from "./QueryEditorComponent"; +import QueryEditorControls from "./QueryEditorControls"; +import "./index.less"; + +function QueryEditor({ + queryText, + schema, + addNewParameter, + dataSources, + dataSource, + canEdit, + isDirty, + isQueryOwner, + updateDataSource, + canExecuteQuery, + executeQuery, + queryExecuting, + saveQuery, + updateQuery, + updateSelectedQuery, + listenForEditorCommand, +}) { + const editorRef = useRef(null); + const autocompleteAvailable = useMemo(() => { + const tokensCount = reduce(schema, (totalLength, table) => totalLength + table.columns.length, 0); + return tokensCount <= 5000; + }, [schema]); + const [autocompleteEnabled, setAutocompleteEnabled] = useState(localOptions.get("liveAutocomplete", true)); + const [selectedText, setSelectedText] = useState(null); + + useEffect( + () => + // `listenForEditorCommand` returns function that removes event listener + listenForEditorCommand((e, command, ...args) => { + const editor = editorRef.current; + if (editor) { + switch (command) { + case "focus": { + editor.focus(); + break; + } + case "paste": { + const [text] = args; + editor.paste(text); + $rootScope.$applyAsync(); + break; + } + default: + break; + } + } + }), + [listenForEditorCommand] + ); + + const handleSelectionChange = useCallback( + text => { + setSelectedText(text); + updateSelectedQuery(text); + }, + [updateSelectedQuery] + ); + + const formatQuery = useCallback(() => { + Query.format(dataSource.syntax || "sql", queryText) + .then(updateQuery) + .catch(error => notification.error(error)); + }, [dataSource.syntax, queryText, updateQuery]); + + const toggleAutocomplete = useCallback(state => { + setAutocompleteEnabled(state); + localOptions.set("liveAutocomplete", state); + }, []); + + const modKey = KeyboardShortcuts.modKey; + + return ( +
+ + + + Add New Parameter ({modKey} + P) + + ), + onClick: addNewParameter, + }} + formatButtonProps={{ + title: ( + + Format Query ({modKey} + Shift + F) + + ), + onClick: formatQuery, + }} + saveButtonProps={ + canEdit && { + title: `${modKey} + S`, + text: ( + + Save + {isDirty ? "*" : null} + + ), + onClick: saveQuery, + } + } + executeButtonProps={{ + title: `${modKey} + Enter`, + disabled: !canExecuteQuery || queryExecuting, + onClick: executeQuery, + text: {selectedText === null ? "Execute" : "Execute Selected"}, + }} + autocompleteToggleProps={{ + available: autocompleteAvailable, + enabled: autocompleteEnabled, + onToggle: toggleAutocomplete, + }} + dataSourceSelectorProps={{ + disabled: !isQueryOwner, + value: dataSource.id, + onChange: updateDataSource, + options: map(dataSources, ds => ({ value: ds.id, label: ds.name })), + }} + /> +
+ ); +} + +QueryEditor.propTypes = { + queryText: PropTypes.string.isRequired, + schema: Schema, + addNewParameter: PropTypes.func.isRequired, + dataSources: PropTypes.arrayOf(DataSource), + dataSource: DataSource, + canEdit: PropTypes.bool.isRequired, + isDirty: PropTypes.bool.isRequired, + isQueryOwner: PropTypes.bool.isRequired, + updateDataSource: PropTypes.func.isRequired, + canExecuteQuery: PropTypes.bool.isRequired, + executeQuery: PropTypes.func.isRequired, + queryExecuting: PropTypes.bool.isRequired, + saveQuery: PropTypes.func.isRequired, + updateQuery: PropTypes.func.isRequired, + updateSelectedQuery: PropTypes.func.isRequired, + listenForEditorCommand: PropTypes.func.isRequired, +}; + +QueryEditor.defaultProps = { + schema: null, + dataSource: {}, + dataSources: [], +}; + +export default function init(ngModule) { + ngModule.component("queryEditor", react2angular(QueryEditor)); +} + +init.init = true; diff --git a/client/app/components/queries/QueryEditor/index.less b/client/app/components/queries/QueryEditor/index.less new file mode 100644 index 000000000..beede5b71 --- /dev/null +++ b/client/app/components/queries/QueryEditor/index.less @@ -0,0 +1,36 @@ +.editor__wrapper { + padding: 15px; + margin-bottom: 10px; + height: 100%; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + + .editor__container { + margin-bottom: 0; + flex: 1 1 auto; + position: relative; + + .ace_editor { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + margin: 0; + } + + &[data-executing] { + .ace_marker-layer { + .ace_selection { + background-color: rgb(255, 210, 181); + } + } + } + } + + .query-editor-controls { + flex: 0 0 auto; + margin-top: 10px; + } +} diff --git a/client/app/pages/queries/query.html b/client/app/pages/queries/query.html index 15ecb22e3..3014f08e7 100644 --- a/client/app/pages/queries/query.html +++ b/client/app/pages/queries/query.html @@ -159,15 +159,13 @@ style="width: 100%; height: 100%;" query-text="query.query" schema="schema" - syntax="dataSource.syntax" can-edit="canEdit" is-dirty="isDirty" is-query-owner="isQueryOwner" - update-data-source="updateDataSource" + update-data-source="handleDataSourceChange" execute-query="executeQuery" query-executing="queryExecuting" can-execute-query="canExecuteQuery()" - listen-for-resize="listenForResize" listen-for-editor-command="listenForEditorCommand" save-query="saveQuery" update-query="updateQuery" diff --git a/client/app/pages/queries/source-view.js b/client/app/pages/queries/source-view.js index 48d4b763c..6b5da0710 100644 --- a/client/app/pages/queries/source-view.js +++ b/client/app/pages/queries/source-view.js @@ -102,7 +102,6 @@ function QuerySourceCtrl( }; $scope.listenForEditorCommand = f => $scope.$on("query-editor.command", f); - $scope.listenForResize = f => $scope.$parent.$on("angular-resizable.resizing", f); $scope.$watch("query.query", newQueryText => { $scope.isDirty = newQueryText !== queryText; diff --git a/client/app/pages/queries/view.js b/client/app/pages/queries/view.js index 17293d7f5..e56941de9 100644 --- a/client/app/pages/queries/view.js +++ b/client/app/pages/queries/view.js @@ -359,6 +359,11 @@ function QueryViewCtrl( AlertDialog.open(title, message, confirm).then(archive); }; + $scope.handleDataSourceChange = dataSourceId => { + $scope.query.data_source_id = dataSourceId; + $scope.updateDataSource(); + }; + $scope.updateDataSource = () => { Events.record("update_data_source", "query", $scope.query.id); localStorage.lastSelectedDataSourceId = $scope.query.data_source_id;