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.dataSources.map(ds => (
-
- {ds.name}
-
- ))}
-
- {this.props.canEdit ? (
-
-
-
- Save
- {this.props.isDirty ? "*" : null}
-
-
- ) : 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
- */}
-
-
-
- {this.state.selectedQueryText == null ? "Execute" : "Execute Selected"}
-
-
-
-
-
-
-
- );
- }
-}
-
-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 (
- toggle(!state)}
- disabled={disabled}>
+
-
+
);
}
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 && (
+
+
+
+ {formatButtonProps.text}
+
+
+ )}
+ {autocompleteToggleProps !== false && (
+
+ )}
+ {dataSourceSelectorProps === false &&
}
+ {dataSourceSelectorProps !== false && (
+
+ {map(dataSourceSelectorProps.options, option => (
+
+ {option.label}
+
+ ))}
+
+ )}
+ {saveButtonProps !== false && (
+
+
+
+ {saveButtonProps.text}
+
+
+ )}
+ {executeButtonProps !== false && (
+
+
+
+ {executeButtonProps.text}
+
+
+ )}
+
+ );
+}
+
+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;