diff --git a/client/app/components/queries/QueryEditor/AngularWrapper.jsx b/client/app/components/queries/QueryEditor/AngularWrapper.jsx
new file mode 100644
index 0000000000..22549c0e53
--- /dev/null
+++ b/client/app/components/queries/QueryEditor/AngularWrapper.jsx
@@ -0,0 +1,179 @@
+// ANGULAR_REMOVE_ME Remove entire file - no mercy
+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 QueryEditor from "@/components/queries/QueryEditor";
+
+function AngularWrapper({
+ 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 })),
+ }}
+ />
+
+ );
+}
+
+AngularWrapper.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,
+};
+
+AngularWrapper.defaultProps = {
+ schema: null,
+ dataSource: {},
+ dataSources: [],
+};
+
+export default function init(ngModule) {
+ ngModule.component("queryEditor", react2angular(AngularWrapper));
+}
+
+init.init = true;
diff --git a/client/app/components/queries/QueryEditor/QueryEditorComponent.jsx b/client/app/components/queries/QueryEditor/QueryEditorComponent.jsx
deleted file mode 100644
index c79ce8bc90..0000000000
--- a/client/app/components/queries/QueryEditor/QueryEditorComponent.jsx
+++ /dev/null
@@ -1,178 +0,0 @@
-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
index 855a3d8869..2e14f40aff 100644
--- a/client/app/components/queries/QueryEditor/QueryEditorControls.jsx
+++ b/client/app/components/queries/QueryEditor/QueryEditorControls.jsx
@@ -1,13 +1,37 @@
-import { map } from "lodash";
-import React from "react";
+import { isFunction, map, filter, fromPairs } from "lodash";
+import React, { useEffect } 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 { KeyboardShortcuts, humanReadableShortcut } from "@/services/keyboard-shortcuts";
import AutocompleteToggle from "./AutocompleteToggle";
import "./QueryEditorControls.less";
+function ButtonTooltip({ title, shortcut, ...props }) {
+ shortcut = humanReadableShortcut(shortcut, 1); // show only primary shortcut
+ title =
+ title && shortcut ? (
+
+ {title} ({shortcut})
+
+ ) : (
+ title || shortcut
+ );
+ return ;
+}
+
+ButtonTooltip.propTypes = {
+ title: PropTypes.node,
+ shortcut: PropTypes.string,
+};
+
+ButtonTooltip.defaultProps = {
+ title: null,
+ shortcut: null,
+};
+
export default function EditorControl({
addParameterButtonProps,
formatButtonProps,
@@ -16,20 +40,34 @@ export default function EditorControl({
autocompleteToggleProps,
dataSourceSelectorProps,
}) {
+ useEffect(() => {
+ const buttons = filter(
+ [addParameterButtonProps, formatButtonProps, saveButtonProps, executeButtonProps],
+ b => b.shortcut && !b.disabled && isFunction(b.onClick)
+ );
+ if (buttons.length > 0) {
+ const shortcuts = fromPairs(map(buttons, b => [b.shortcut, b.onClick]));
+ KeyboardShortcuts.bind(shortcuts);
+ return () => {
+ KeyboardShortcuts.unbind(shortcuts);
+ };
+ }
+ }, [addParameterButtonProps, formatButtonProps, saveButtonProps, executeButtonProps]);
+
return (
{addParameterButtonProps !== false && (
-
+
-
+
)}
{formatButtonProps !== false && (
-
+
{formatButtonProps.text}
-
+
)}
{autocompleteToggleProps !== false && (
)}
- {dataSourceSelectorProps === false &&
}
+ {dataSourceSelectorProps === false &&
}
{dataSourceSelectorProps !== false && (
);
@@ -96,6 +134,7 @@ const ButtonPropsPropType = PropTypes.oneOfType([
disabled: PropTypes.bool,
onClick: PropTypes.func,
text: PropTypes.node,
+ shortcut: PropTypes.string,
}),
]);
diff --git a/client/app/components/queries/QueryEditor/QueryEditorControls.less b/client/app/components/queries/QueryEditor/QueryEditorControls.less
index c80e75dc14..f81c152ae5 100644
--- a/client/app/components/queries/QueryEditor/QueryEditorControls.less
+++ b/client/app/components/queries/QueryEditor/QueryEditorControls.less
@@ -20,4 +20,9 @@
margin-left: 5px;
}
}
+
+ .query-editor-controls-spacer {
+ flex: 1 1 auto;
+ height: 35px; // same as Antd