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 && ( - + - + )} {autocompleteToggleProps !== false && ( )} - {dataSourceSelectorProps === false && } + {dataSourceSelectorProps === false && } {dataSourceSelectorProps !== false && ( + } } diff --git a/client/app/components/queries/QueryEditor/index.jsx b/client/app/components/queries/QueryEditor/index.jsx index 6be0a90392..869e913334 100644 --- a/client/app/components/queries/QueryEditor/index.jsx +++ b/client/app/components/queries/QueryEditor/index.jsx @@ -1,181 +1,183 @@ -import { map, reduce } from "lodash"; -import React, { useRef, useState, useMemo, useEffect, useCallback } from "react"; +import React, { useEffect, useMemo, useRef, useState, useCallback, useImperativeHandle } 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 cx from "classnames"; +import { AceEditor, snippetsModule, updateSchemaCompleter } from "./ace"; +import resizeObserver from "@/services/resizeObserver"; +import { QuerySnippet } from "@/services/query-snippet"; + 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 editorProps = { $blockScrolling: Infinity }; + +const QueryEditor = React.forwardRef(function( + { className, syntax, value, autocompleteEnabled, schema, onChange, onSelectionChange, ...props }, + ref +) { + const [container, setContainer] = useState(null); 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] + + // 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( - text => { - setSelectedText(text); - updateSelectedQuery(text); + selection => { + const { editor } = editorRef.current; + const rawSelectedQueryText = editor.session.doc.getTextRange(selection.getRange()); + const selectedQueryText = rawSelectedQueryText.length > 1 ? rawSelectedQueryText : null; + onSelectionChange(selectedQueryText); }, - [updateSelectedQuery] + [onSelectionChange] ); - const formatQuery = useCallback(() => { - Query.format(dataSource.syntax || "sql", queryText) - .then(updateQuery) - .catch(error => notification.error(error)); - }, [dataSource.syntax, queryText, updateQuery]); + 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")); - const toggleAutocomplete = useCallback(state => { - setAutocompleteEnabled(state); - localOptions.set("liveAutocomplete", state); + // 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(); }, []); - const modKey = KeyboardShortcuts.modKey; + 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 ( -
- + - - - 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, + 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, }; QueryEditor.defaultProps = { - schema: null, - dataSource: {}, - dataSources: [], + className: null, + syntax: null, + value: null, + autocompleteEnabled: true, + schema: [], + onChange: () => {}, + onSelectionChange: () => {}, }; -export default function init(ngModule) { - ngModule.component("queryEditor", react2angular(QueryEditor)); -} +QueryEditor.Controls = QueryEditorControls; -init.init = true; +export default QueryEditor; diff --git a/client/app/components/queries/QueryEditor/index.less b/client/app/components/queries/QueryEditor/index.less index beede5b719..a6ac187c69 100644 --- a/client/app/components/queries/QueryEditor/index.less +++ b/client/app/components/queries/QueryEditor/index.less @@ -1,36 +1,13 @@ -.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; +.query-editor-container { + margin-bottom: 0; + position: relative; + + .ace_editor { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + margin: 0; } } diff --git a/client/app/lib/hooks/useQueryResult.js b/client/app/lib/hooks/useQueryResult.js index 942bca3c4d..fba258f947 100644 --- a/client/app/lib/hooks/useQueryResult.js +++ b/client/app/lib/hooks/useQueryResult.js @@ -2,7 +2,7 @@ import { includes, get, invoke } from "lodash"; import { useState, useEffect, useRef } from "react"; function getQueryResultStatus(queryResult) { - return invoke(queryResult, "getStatus") || "waiting"; + return invoke(queryResult, "getStatus") || null; } function isFinalStatus(status) { diff --git a/client/app/pages/queries/QuerySource.jsx b/client/app/pages/queries/QuerySource.jsx index 8b584455b1..4f505bf80e 100644 --- a/client/app/pages/queries/QuerySource.jsx +++ b/client/app/pages/queries/QuerySource.jsx @@ -1,26 +1,33 @@ -import { filter, find, map, clone, includes } from "lodash"; +import { isEmpty, extend, filter, find, map, includes, reduce } from "lodash"; import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"; import PropTypes from "prop-types"; import { react2angular } from "react2angular"; +import { useDebouncedCallback } from "use-debounce"; import Select from "antd/lib/select"; import { Parameters } from "@/components/Parameters"; import { EditVisualizationButton } from "@/components/EditVisualizationButton"; import { QueryControlDropdown } from "@/components/EditVisualizationButton/QueryControlDropdown"; +import QueryEditor from "@/components/queries/QueryEditor"; import { TimeAgo } from "@/components/TimeAgo"; import EmbedQueryDialog from "@/components/queries/EmbedQueryDialog"; import AddToDashboardDialog from "@/components/queries/AddToDashboardDialog"; +import EditParameterSettingsDialog from "@/components/EditParameterSettingsDialog"; import { routesToAngularRoutes } from "@/lib/utils"; -import useQueryResult from "@/lib/hooks/useQueryResult"; import { durationHumanize, prettySize } from "@/filters"; +import { currentUser } from "@/services/auth"; import { Query } from "@/services/query"; import { DataSource, SCHEMA_NOT_SUPPORTED } from "@/services/data-source"; import notification from "@/services/notification"; import recordEvent from "@/services/recordEvent"; +import navigateTo from "@/services/navigateTo"; +import localOptions from "@/lib/localOptions"; import QueryPageHeader from "./components/QueryPageHeader"; import QueryVisualizationTabs from "./components/QueryVisualizationTabs"; +import QueryExecutionStatus from "./components/QueryExecutionStatus"; import SchemaBrowser from "./components/SchemaBrowser"; import useVisualizationTabHandler from "./utils/useVisualizationTabHandler"; +import useQueryExecute from "./utils/useQueryExecute"; import { updateQuery, deleteQueryVisualization, addQueryVisualization, editQueryVisualization } from "./utils"; import "./query-source.less"; @@ -54,6 +61,7 @@ function chooseDataSourceId(dataSourceIds, availableDataSources) { function QuerySource(props) { const [query, setQuery] = useState(props.query); + const [originalQuerySource, setOriginalQuerySource] = useState(props.query.query); const [allDataSources, setAllDataSources] = useState([]); const [dataSourcesLoaded, setDataSourcesLoaded] = useState(false); const dataSources = useMemo(() => filter(allDataSources, ds => !ds.view_only || ds.id === query.data_source_id), [ @@ -65,9 +73,33 @@ function QuerySource(props) { const refreshSchemaTokenRef = useRef(null); const [selectedTab, setSelectedTab] = useVisualizationTabHandler(query.visualizations); const parameters = useMemo(() => query.getParametersDefs(), [query]); + const [dirtyParameters, setDirtyParameters] = useState(query.getParameters().hasPendingValues()); - const queryResult = useMemo(() => query.getQueryResult(), [query]); - const queryResultData = useQueryResult(queryResult); + const { queryResult, queryResultData, isQueryExecuting, executeQuery, executeAdhocQuery } = useQueryExecute(query); + + 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); + + const handleSelectionChange = useCallback(text => { + setSelectedText(text); + }, []); + + const toggleAutocomplete = useCallback(state => { + setAutocompleteEnabled(state); + localOptions.set("liveAutocomplete", state); + }, []); + + useEffect(() => { + const updatedDirtyParameters = query.getParameters().hasPendingValues(); + if (updatedDirtyParameters !== dirtyParameters) { + setDirtyParameters(query.getParameters().hasPendingValues()); + } + }, [dirtyParameters, parameters, query]); useEffect(() => { let cancelDataSourceLoading = false; @@ -100,6 +132,18 @@ function QuerySource(props) { [dataSource] ); + const [handleQueryEditorChange] = useDebouncedCallback(queryText => { + setQuery(extend(query.clone(), { query: queryText })); + }, 200); + + const formatQuery = useCallback(() => { + Query.format(dataSource.syntax || "sql", query.query) + .then(queryText => { + setQuery(extend(query.clone(), { query: queryText })); + }) + .catch(error => notification.error(error)); + }, [dataSource, query]); + useEffect(() => { reloadSchema(); }, [reloadSchema]); @@ -123,10 +167,11 @@ function QuerySource(props) { } if (query.data_source_id !== dataSourceId) { recordEvent("update_data_source", "query", query.id, { dataSourceId }); - const newQuery = clone(query); - newQuery.data_source_id = dataSourceId; - newQuery.latest_query_data_id = null; - newQuery.latest_query_data = null; + const newQuery = extend(query.clone(), { + data_source_id: dataSourceId, + latest_query_data_id: null, + latest_query_data: null, + }); setQuery(newQuery); updateQuery( newQuery, @@ -141,6 +186,16 @@ function QuerySource(props) { [query] ); + const saveQuery = useCallback(() => { + updateQuery(query).then(updatedQuery => { + setQuery(updatedQuery); + setOriginalQuerySource(updatedQuery.query); + if (updatedQuery.id !== query.id) { + navigateTo(updatedQuery.getSourceLink()); + } + }); + }, [query]); + useEffect(() => { // choose data source id for new queries if (dataSourcesLoaded && query.isNew()) { @@ -154,8 +209,6 @@ function QuerySource(props) { } }, [query, dataSourcesLoaded, dataSources, handleDataSourceChange]); - const queryExecuting = false; // TODO: Replace with real value - const openAddToDashboardDialog = useCallback( visualizationId => { const visualization = find(query.visualizations, { id: visualizationId }); @@ -172,6 +225,53 @@ function QuerySource(props) { [query] ); + const openAddNewParameterDialog = useCallback(() => { + EditParameterSettingsDialog.showModal({ + parameter: { + title: null, + name: "", + type: "text", + value: null, + }, + existingParams: map(query.getParameters().get(), p => p.name), + }).result.then(param => { + const newQuery = query.clone(); + param = newQuery.getParameters().add(param); + if (editorRef.current) { + editorRef.current.paste(param.toQueryTextFragment()); + editorRef.current.focus(); + } + setQuery(newQuery); + }); + }, [query]); + + const handleSchemaItemSelect = useCallback(schemaItem => { + if (editorRef.current) { + editorRef.current.paste(schemaItem); + } + }, []); + + const canExecuteQuery = useMemo( + () => + !isEmpty(query.query) && + !isQueryExecuting && + !dirtyParameters && + (query.is_safe || (currentUser.hasPermission("execute_query") && dataSource && !dataSource.view_only)), + [isQueryExecuting, dirtyParameters, query, dataSource] + ); + const isDirty = query.query !== originalQuerySource; + + const doExecuteQuery = useCallback(() => { + if (!canExecuteQuery) { + return; + } + if (isDirty || !isEmpty(selectedText)) { + executeAdhocQuery(selectedText); + } else { + executeQuery(); + } + }, [canExecuteQuery, isDirty, selectedText, executeQuery, executeAdhocQuery]); + return (
@@ -198,7 +298,7 @@ function QuerySource(props) {
- reloadSchema(true)} /> + reloadSchema(true)} onItemSelect={handleSchemaItemSelect} />
@@ -207,8 +307,65 @@ function QuerySource(props) {
-
- Editor +
+
+ + + + Save + {isDirty ? "*" : null} + + ), + shortcut: "mod+s", + onClick: saveQuery, + } + } + executeButtonProps={{ + disabled: !canExecuteQuery, + shortcut: "mod+enter, alt+enter", + onClick: doExecuteQuery, + text: {selectedText === null ? "Execute" : "Execute Selected"}, + }} + autocompleteToggleProps={{ + available: autocompleteAvailable, + enabled: autocompleteEnabled, + onToggle: toggleAutocomplete, + }} + dataSourceSelectorProps={ + dataSource + ? { + disabled: !query.can_edit, + value: dataSource.id, + onChange: handleDataSourceChange, + options: map(dataSources, ds => ({ value: ds.id, label: ds.name })), + } + : false + } + /> +
@@ -217,10 +374,35 @@ function QuerySource(props) { style={{ left: 0, top: 0, right: 0, bottom: 0 }}> {query.hasParameters() && (
- + setDirtyParameters(query.getParameters().hasPendingValues())} + onValuesChange={() => { + setDirtyParameters(false); + doExecuteQuery(); + }} + onParametersEdit={() => { + // save if query clean + // https://discuss.redash.io/t/query-unsaved-changes-indication/3302/5 + if (!isDirty) { + saveQuery(); + } + }} + /> +
+ )} + {queryResult && queryResultData.status !== "done" && ( +
+ console.log("Query execution cancelled")} + />
)} -
{/* Query Execution Status */}
{queryResultData.status === "done" && (
@@ -279,7 +461,7 @@ function QuerySource(props) { - {!queryExecuting && ( + {!isQueryExecuting && ( {durationHumanize(queryResultData.runtime)} runtime )} - {queryExecuting && Running…} + {isQueryExecuting && Running…} {queryResultData.metadata.data_scanned && ( diff --git a/client/app/pages/queries/components/QueryExecutionStatus.jsx b/client/app/pages/queries/components/QueryExecutionStatus.jsx new file mode 100644 index 0000000000..2ce42ab6c7 --- /dev/null +++ b/client/app/pages/queries/components/QueryExecutionStatus.jsx @@ -0,0 +1,67 @@ +import { includes } from "lodash"; +import React from "react"; +import PropTypes from "prop-types"; +import Alert from "antd/lib/alert"; +import Button from "antd/lib/button"; +import { Timer } from "@/components/Timer"; + +export default function QueryExecutionStatus({ status, updatedAt, error, onCancel }) { + const alertType = status === "failed" ? "error" : "info"; + const showTimer = status !== "failed" && updatedAt; + const canCancel = includes(["waiting", "processing"], status); + let message = null; + + switch (status) { + case "waiting": + message = Query in queue…; + break; + case "processing": + message = Executing query…; + break; + case "loading-result": + message = Loading results…; + break; + case "failed": + message = ( + + Error running query: {error} + + ); + break; + // no default + } + + return ( + +
+ {message} {showTimer && } +
+
+ {canCancel && ( + + )} +
+
+ } + /> + ); +} + +QueryExecutionStatus.propTypes = { + status: PropTypes.string, + updatedAt: PropTypes.any, + error: PropTypes.string, + onCancel: PropTypes.func, +}; + +QueryExecutionStatus.defaultProps = { + status: "waiting", + updatedAt: null, + error: null, + onCancel: () => {}, +}; diff --git a/client/app/pages/queries/components/QueryVisualizationTabs.jsx b/client/app/pages/queries/components/QueryVisualizationTabs.jsx index 9340cc0412..8b2c2adbae 100644 --- a/client/app/pages/queries/components/QueryVisualizationTabs.jsx +++ b/client/app/pages/queries/components/QueryVisualizationTabs.jsx @@ -47,8 +47,16 @@ TabWithDeleteButton.propTypes = { }; TabWithDeleteButton.defaultProps = { canDelete: false, onDelete: () => {} }; +const defaultVisualizations = [ + { + type: "TABLE", + name: "Table", + id: null, + options: {}, + }, +]; + export default function QueryVisualizationTabs({ - visualizations, queryResult, selectedTab, showNewVisualizationButton, @@ -56,7 +64,13 @@ export default function QueryVisualizationTabs({ onChangeTab, onClickNewVisualization, onDeleteVisualization, + ...props }) { + const visualizations = useMemo( + () => (props.visualizations.length > 0 ? props.visualizations : defaultVisualizations), + [props.visualizations] + ); + const tabsProps = {}; if (find(visualizations, { id: selectedTab })) { tabsProps.activeKey = `${selectedTab}`; diff --git a/client/app/pages/queries/components/SchemaBrowser.jsx b/client/app/pages/queries/components/SchemaBrowser.jsx index 2b9f3eade9..6cb41ad03d 100644 --- a/client/app/pages/queries/components/SchemaBrowser.jsx +++ b/client/app/pages/queries/components/SchemaBrowser.jsx @@ -110,7 +110,7 @@ function applyFilter(schema, filterString) { ); } -export default function SchemaBrowser({ schema, onRefresh, ...props }) { +export default function SchemaBrowser({ schema, onRefresh, onItemSelect, ...props }) { const [filterString, setFilterString] = useState(""); const filteredSchema = useMemo(() => applyFilter(schema, filterString), [schema, filterString]); const [expandedFlags, setExpandedFlags] = useState({}); @@ -176,6 +176,7 @@ export default function SchemaBrowser({ schema, onRefresh, ...props }) { item={item} expanded={expandedFlags[item.name]} onToggle={() => toggleTable(item.name)} + onSelect={onItemSelect} /> ); }} @@ -190,9 +191,11 @@ export default function SchemaBrowser({ schema, onRefresh, ...props }) { SchemaBrowser.propTypes = { schema: PropTypes.arrayOf(SchemaItemType), onRefresh: PropTypes.func, + onItemSelect: PropTypes.func, }; SchemaBrowser.defaultProps = { schema: [], onRefresh: () => {}, + onItemSelect: () => {}, }; diff --git a/client/app/pages/queries/query-source.less b/client/app/pages/queries/query-source.less index 69cebe8c1c..8a951918e0 100644 --- a/client/app/pages/queries/query-source.less +++ b/client/app/pages/queries/query-source.less @@ -2,3 +2,29 @@ page-query-source { display: flex; flex-grow: 1; } + +.query-editor-wrapper { + padding: 15px; + margin-bottom: 10px; + height: 100%; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + + .query-editor-container { + flex: 1 1 auto; + + &[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/utils/archiveQuery.jsx b/client/app/pages/queries/utils/archiveQuery.jsx index d5f13750b5..14c32673a7 100644 --- a/client/app/pages/queries/utils/archiveQuery.jsx +++ b/client/app/pages/queries/utils/archiveQuery.jsx @@ -1,4 +1,4 @@ -import { clone, extend } from "lodash"; +import { extend } from "lodash"; import React from "react"; import Modal from "antd/lib/modal"; import { Query } from "@/services/query"; @@ -32,7 +32,9 @@ function doArchiveQuery(query) { // Prettier will put `.$promise` before `.catch` on next line :facepalm: // prettier-ignore return Query.delete({ id: query.id }).$promise - .then(() => Promise.resolve(extend(clone(query), { is_archived: true, schedule: null }))) + .then(() => { + return extend(query.clone(), { is_archived: true, schedule: null }); + }) .catch(error => { notification.error("Query could not be archived."); return Promise.reject(error); diff --git a/client/app/pages/queries/utils/editQueryVisualization.js b/client/app/pages/queries/utils/editQueryVisualization.js index 86233491c6..c3a57e662c 100644 --- a/client/app/pages/queries/utils/editQueryVisualization.js +++ b/client/app/pages/queries/utils/editQueryVisualization.js @@ -1,4 +1,4 @@ -import { clone, extend, filter, get } from "lodash"; +import { clone, extend, filter } from "lodash"; import EditVisualizationDialog from "@/visualizations/EditVisualizationDialog"; export function editQueryVisualization(query, queryResult, visualization) { diff --git a/client/app/pages/queries/utils/updateQuery.jsx b/client/app/pages/queries/utils/updateQuery.jsx index 0876359444..f3565bc4b2 100644 --- a/client/app/pages/queries/utils/updateQuery.jsx +++ b/client/app/pages/queries/utils/updateQuery.jsx @@ -1,4 +1,4 @@ -import { isNil, isObject, clone, extend, keys, map, omit, pick } from "lodash"; +import { isNil, isObject, extend, keys, map, omit, pick, uniq } from "lodash"; import React from "react"; import Modal from "antd/lib/modal"; import { Query } from "@/services/query"; @@ -72,11 +72,11 @@ function doSaveQuery(data, { canOverwrite = false } = {}) { }); } -export default function saveQuery(query, data, { successMessage = "Query saved" } = {}) { +export default function saveQuery(query, data = null, { successMessage = "Query saved" } = {}) { if (isObject(data)) { // Don't save new query with partial data if (query.isNew()) { - return Promise.resolve(extend(clone(query), data)); + return Promise.resolve(extend(query.clone(), data)); } data = { ...data, id: query.id, version: query.version }; } else { @@ -99,7 +99,7 @@ export default function saveQuery(query, data, { successMessage = "Query saved" if (!isNil(successMessage)) { notification.success(successMessage); } - return extend(clone(query), pick(updatedQuery, keys(data))); + return extend(query.clone(), pick(updatedQuery, uniq(["id", "version", ...keys(data)]))); }) .catch(error => { const notificationOptions = {}; diff --git a/client/app/pages/queries/utils/useQueryExecute.js b/client/app/pages/queries/utils/useQueryExecute.js index c03eae2733..978e56257c 100644 --- a/client/app/pages/queries/utils/useQueryExecute.js +++ b/client/app/pages/queries/utils/useQueryExecute.js @@ -1,9 +1,8 @@ -import { useState, useMemo } from "react"; -import { includes } from "lodash"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { noop, includes } from "lodash"; import useQueryResult from "@/lib/hooks/useQueryResult"; -import { useCallback } from "react"; import { $location } from "@/services/ng"; -import { useEffect } from "react"; +import recordEvent from "@/services/recordEvent"; function getMaxAge() { const maxAge = $location.search().maxAge; @@ -11,21 +10,34 @@ function getMaxAge() { } export default function useQueryExecute(query) { - const [queryResult, setQueryResult] = useState(query.getQueryResult(getMaxAge())); + // Query result should be initialized only once on component mount + const initializeQueryResultRef = useRef(() => + query.hasResult() || query.paramsRequired() ? query.getQueryResult(getMaxAge()) : null + ); + const [queryResult, setQueryResult] = useState(initializeQueryResultRef.current()); + initializeQueryResultRef.current = noop; + const queryResultData = useQueryResult(queryResult); - const isQueryExecuting = useMemo(() => !includes(["done", "failed"], queryResultData.status), [ + const isQueryExecuting = useMemo(() => queryResult && !includes(["done", "failed"], queryResultData.status), [ + queryResult, queryResultData.status, ]); - const executeQuery = useCallback(() => setQueryResult(query.getQueryResult(0)), [query]); + const executeQuery = useCallback(() => { + recordEvent("execute", "query", query.id); + setQueryResult(query.getQueryResult(0)); + }, [query]); const executeAdhocQuery = useCallback( - selectedQueryText => setQueryResult(query.getQueryResultByText(0, selectedQueryText)), + selectedQueryText => { + recordEvent("execute", "query", query.id); + setQueryResult(query.getQueryResultByText(0, selectedQueryText)); + }, [query] ); useEffect(() => { - if (!isQueryExecuting && queryResult.query_result.query === query.query) { + if (!isQueryExecuting && queryResult && queryResult.query_result.query === query.query) { query.latest_query_data_id = queryResult.getId(); query.queryResult = queryResult; } diff --git a/client/app/pages/queries/utils/useVisualizationTabHandler.js b/client/app/pages/queries/utils/useVisualizationTabHandler.js index 9829c3fe80..23b18e0205 100644 --- a/client/app/pages/queries/utils/useVisualizationTabHandler.js +++ b/client/app/pages/queries/utils/useVisualizationTabHandler.js @@ -8,7 +8,7 @@ function updateUrlHash(...args) { } export default function useVisualizationTabHandler(visualizations) { - const firstVisualization = useMemo(() => first(orderBy(visualizations, ["id"])), [visualizations]); + const firstVisualization = useMemo(() => first(orderBy(visualizations, ["id"])) || {}, [visualizations]); const [selectedTab, setSelectedTab] = useState(+$location.hash() || firstVisualization.id); useEffect(() => { diff --git a/client/app/services/keyboard-shortcuts.js b/client/app/services/keyboard-shortcuts.js index 656c7eab62..39034d990c 100644 --- a/client/app/services/keyboard-shortcuts.js +++ b/client/app/services/keyboard-shortcuts.js @@ -1,9 +1,26 @@ -import { each, trim, without } from "lodash"; +import { each, filter, map, toLower, toString, trim, upperFirst, without } from "lodash"; import Mousetrap from "mousetrap"; import "mousetrap/plugins/global-bind/mousetrap-global-bind"; +const modKey = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? "Cmd" : "Ctrl"; + export let KeyboardShortcuts = null; // eslint-disable-line import/no-mutable-exports +export function humanReadableShortcut(shortcut, limit = Infinity) { + const modifiers = { + mod: upperFirst(modKey), + }; + + shortcut = toLower(toString(shortcut)); + shortcut = filter(map(shortcut.split(","), trim), s => s !== "").slice(0, limit); + shortcut = map(shortcut, sc => { + sc = filter(map(sc.split("+")), s => s !== ""); + return map(sc, s => modifiers[s] || upperFirst(s)).join(" + "); + }).join(", "); + + return shortcut !== "" ? shortcut : null; +} + const handlers = {}; function onShortcut(event, shortcut) { @@ -13,7 +30,7 @@ function onShortcut(event, shortcut) { } function KeyboardShortcutsService() { - this.modKey = /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? "Cmd" : "Ctrl"; + this.modKey = modKey; this.bind = function bind(keymap) { each(keymap, (fn, key) => { diff --git a/client/app/services/query.js b/client/app/services/query.js index cb3c9a3970..8a7471072d 100644 --- a/client/app/services/query.js +++ b/client/app/services/query.js @@ -1,7 +1,22 @@ import moment from "moment"; import debug from "debug"; import Mustache from "mustache"; -import { zipObject, isEmpty, map, filter, includes, union, uniq, has, identity, extend, each, some } from "lodash"; +import { + zipObject, + isEmpty, + map, + filter, + includes, + union, + uniq, + has, + identity, + extend, + each, + some, + clone, + find, +} from "lodash"; import { Parameter } from "./parameters"; @@ -54,6 +69,13 @@ class Parameters { updateParameters(update) { if (this.query.query && this.query.query === this.cachedQueryText) { + const parameters = this.query.options.parameters; + const hasUnprocessedParameters = find(parameters, p => !(p instanceof Parameter)); + if (hasUnprocessedParameters) { + this.query.options.parameters = map(parameters, p => + p instanceof Parameter ? p : Parameter.create(p, this.query.id) + ); + } return; } @@ -411,6 +433,13 @@ function QueryResource($resource, $http, $location, $q, currentUser, QueryResult return this.getParameters().get(update); }; + QueryService.prototype.clone = function cloneQuery() { + const newQuery = clone(this); + newQuery.$parameters = null; + newQuery.getParameters(); + return newQuery; + }; + return QueryService; }