diff --git a/client/app/components/queries/ApiKeyDialog/index.jsx b/client/app/components/queries/ApiKeyDialog/index.jsx index 29488d7931..96641a7051 100644 --- a/client/app/components/queries/ApiKeyDialog/index.jsx +++ b/client/app/components/queries/ApiKeyDialog/index.jsx @@ -1,4 +1,4 @@ -import { clone, extend } from "lodash"; +import { extend } from "lodash"; import React, { useMemo, useState, useCallback } from "react"; import PropTypes from "prop-types"; import Modal from "antd/lib/modal"; @@ -22,7 +22,7 @@ function ApiKeyDialog({ dialog, ...props }) { .post(`api/queries/${query.id}/regenerate_api_key`) .success(data => { setUpdatingApiKey(false); - setQuery(extend(clone(query), { api_key: data.api_key })); + setQuery(extend(query.clone(), { api_key: data.api_key })); }) .error(() => { setUpdatingApiKey(false); diff --git a/client/app/components/queries/QueryEditor/index.jsx b/client/app/components/queries/QueryEditor/index.jsx index 869e913334..fd58cc9c0b 100644 --- a/client/app/components/queries/QueryEditor/index.jsx +++ b/client/app/components/queries/QueryEditor/index.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState, useCallback, useImperativeHandle } from "react"; +import React, { useEffect, useMemo, useState, useCallback, useImperativeHandle } from "react"; import PropTypes from "prop-types"; import cx from "classnames"; import { AceEditor, snippetsModule, updateSchemaCompleter } from "./ace"; @@ -15,7 +15,7 @@ const QueryEditor = React.forwardRef(function( ref ) { const [container, setContainer] = useState(null); - const editorRef = useRef(null); + const [editorRef, setEditorRef] = useState(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); @@ -44,17 +44,19 @@ const QueryEditor = React.forwardRef(function( ); useEffect(() => { - if (editorRef.current) { - const { editor } = editorRef.current; - updateSchemaCompleter(editor.id, schema); // TODO: cleanup? + if (editorRef) { + const editorId = editorRef.editor.id; + updateSchemaCompleter(editorId, schema); + return () => { + updateSchemaCompleter(editorId, null); + }; } - }, [schema]); + }, [schema, editorRef]); useEffect(() => { function resize() { - if (editorRef.current) { - const { editor } = editorRef.current; - editor.resize(); + if (editorRef) { + editorRef.editor.resize(); } } @@ -63,16 +65,15 @@ const QueryEditor = React.forwardRef(function( const unwatch = resizeObserver(container, resize); return unwatch; } - }, [container]); + }, [container, editorRef]); const handleSelectionChange = useCallback( selection => { - const { editor } = editorRef.current; - const rawSelectedQueryText = editor.session.doc.getTextRange(selection.getRange()); + const rawSelectedQueryText = editorRef.editor.session.doc.getTextRange(selection.getRange()); const selectedQueryText = rawSelectedQueryText.length > 1 ? rawSelectedQueryText : null; onSelectionChange(selectedQueryText); }, - [onSelectionChange] + [editorRef, onSelectionChange] ); const initEditor = useCallback(editor => { @@ -113,8 +114,8 @@ const QueryEditor = React.forwardRef(function( ref, () => ({ paste: text => { - if (editorRef.current) { - const { editor } = editorRef.current; + if (editorRef) { + const { editor } = editorRef; editor.session.doc.replace(editor.selection.getRange(), text); const range = editor.selection.getRange(); onChange(editor.session.getValue()); @@ -122,19 +123,18 @@ const QueryEditor = React.forwardRef(function( } }, focus: () => { - if (editorRef.current) { - const { editor } = editorRef.current; - editor.focus(); + if (editorRef) { + editorRef.editor.focus(); } }, }), - [onChange] + [editorRef, onChange] ); return (
{ - if (data.schema) { - return data.schema; - } else if (data.error.code === SCHEMA_NOT_SUPPORTED) { - return []; - } - return Promise.reject(new Error("Schema refresh failed.")); - }) - .catch(() => { - notification.error("Schema refresh failed.", "Please try again later."); - return Promise.resolve([]); - }); -} +import "./query-source.less"; function chooseDataSourceId(dataSourceIds, availableDataSources) { dataSourceIds = map(dataSourceIds, v => parseInt(v, 10)); @@ -72,20 +48,12 @@ 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), [ - allDataSources, - query.data_source_id, - ]); - const dataSource = useMemo(() => find(dataSources, { id: query.data_source_id }) || null, [query, dataSources]); - const [schema, setSchema] = useState([]); - const refreshSchemaTokenRef = useRef(null); - const [selectedTab, setSelectedTab] = useVisualizationTabHandler(query.visualizations); - const parameters = useMemo(() => query.getParametersDefs(), [query]); - const [dirtyParameters, setDirtyParameters] = useState(query.getParameters().hasPendingValues()); + const { query, setQuery, isDirty, saveQuery } = useQuery(props.query); + const { dataSourcesLoaded, dataSources, dataSource } = useQueryDataSources(query); + const [schema, refreshSchema] = useDataSourceSchema(dataSource); + const queryFlags = useQueryFlags(query, dataSource); + const [parameters, areParametersDirty, updateParametersDirtyFlag] = useQueryParameters(query); + const [selectedVisualization, setSelectedVisualization] = useVisualizationTabHandler(query.visualizations); const { queryResult, @@ -98,76 +66,12 @@ function QuerySource(props) { } = 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; - DataSource.query().$promise.then(data => { - if (!cancelDataSourceLoading) { - setDataSourcesLoaded(true); - setAllDataSources(data); - } - }); - - return () => { - // cancel pending operations - cancelDataSourceLoading = true; - refreshSchemaTokenRef.current = null; - }; - }, []); - - const reloadSchema = useCallback( - (refresh = undefined) => { - const refreshToken = Math.random() - .toString(36) - .substr(2); - refreshSchemaTokenRef.current = refreshToken; - getSchema(dataSource, refresh).then(data => { - if (refreshSchemaTokenRef.current === refreshToken) { - setSchema(data); - } - }); - }, - [dataSource] - ); + const [autocompleteAvailable, autocompleteEnabled, toggleAutocomplete] = useAutocompleteFlags(schema); 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]); - useEffect(() => { recordEvent("view_source", "query", query.id); }, [query.id]); @@ -176,6 +80,10 @@ function QuerySource(props) { document.title = query.name; }, [query.name]); + const updateQuery = useUpdateQuery(query, setQuery); + const updateQueryDescription = useUpdateQueryDescription(query, setQuery); + const formatQuery = useFormatQuery(query, dataSource ? dataSource.syntax : null, setQuery); + const handleDataSourceChange = useCallback( dataSourceId => { if (dataSourceId) { @@ -187,38 +95,21 @@ function QuerySource(props) { } if (query.data_source_id !== dataSourceId) { recordEvent("update_data_source", "query", query.id, { dataSourceId }); - const newQuery = extend(query.clone(), { + const updates = { data_source_id: dataSourceId, latest_query_data_id: null, latest_query_data: null, - }); - setQuery(newQuery); - updateQuery( - newQuery, - { - data_source_id: newQuery.data_source_id, - latest_query_data_id: newQuery.latest_query_data_id, - }, - { successMessage: null } // show message only on error - ).then(setQuery); + }; + setQuery(extend(query.clone(), updates)); + updateQuery(updates, { successMessage: null }); // show message only on error } }, - [query] + [query, setQuery, updateQuery] ); - 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()) { + if (dataSourcesLoaded && queryFlags.isNew) { const firstDataSourceId = dataSources.length > 0 ? dataSources[0].id : null; handleDataSourceChange( chooseDataSourceId( @@ -227,68 +118,25 @@ function QuerySource(props) { ) ); } - }, [query, dataSourcesLoaded, dataSources, handleDataSourceChange]); - - const openAddToDashboardDialog = useCallback( - visualizationId => { - const visualization = find(query.visualizations, { id: visualizationId }); - AddToDashboardDialog.showModal({ visualization }); - }, - [query] - ); - - const openEmbedDialog = useCallback( - (unused, visualizationId) => { - const visualization = find(query.visualizations, { id: visualizationId }); - EmbedQueryDialog.showModal({ query, visualization }); - }, - [query] - ); + }, [query.data_source_id, queryFlags.isNew, dataSourcesLoaded, dataSources, handleDataSourceChange]); - const editSchedule = useCallback(() => { - const canScheduleQuery = true; // TODO: Use real value - if (!query.can_edit || !canScheduleQuery) { - return; + const openAddToDashboardDialog = useAddToDashboardDialog(query); + const openEmbedDialog = useEmbedDialog(query); + const editSchedule = useEditScheduleDialog(query, setQuery); + const openAddNewParameterDialog = useAddNewParameterDialog(query, (newQuery, param) => { + if (editorRef.current) { + editorRef.current.paste(param.toQueryTextFragment()); + editorRef.current.focus(); } + setQuery(newQuery); + }); - const intervals = clientConfig.queryRefreshIntervals; - const allowedIntervals = policy.getQueryRefreshIntervals(); - const refreshOptions = isArray(allowedIntervals) ? intersection(intervals, allowedIntervals) : intervals; - - ScheduleDialog.showModal({ - schedule: query.schedule, - refreshOptions, - }).result.then(schedule => { - updateQuerySchedule(query, schedule).then(setQuery); - }); - }, [query]); - - const doUpdateQueryDescription = useCallback( - description => { - updateQueryDescription(query, description).then(setQuery); - }, - [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 addVisualization = useEditVisualizationDialog(query, queryResult, (newQuery, visualization) => { + setQuery(newQuery); + setSelectedVisualization(visualization.id); + }); + const editVisualization = useEditVisualizationDialog(query, queryResult, newQuery => setQuery(newQuery)); + const deleteVisualization = useDeleteVisualization(query, setQuery); const handleSchemaItemSelect = useCallback(schemaItem => { if (editorRef.current) { @@ -296,15 +144,13 @@ function QuerySource(props) { } }, []); - 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 canExecuteQuery = useMemo(() => queryFlags.canExecute && !isQueryExecuting && !areParametersDirty, [ + isQueryExecuting, + areParametersDirty, + queryFlags.canExecute, + ]); + + const [selectedText, setSelectedText] = useState(null); const doExecuteQuery = useCallback(() => { if (!canExecuteQuery) { @@ -320,7 +166,13 @@ function QuerySource(props) { return (
- +
- reloadSchema(true)} onItemSelect={handleSchemaItemSelect} /> + refreshSchema(true)} + onItemSelect={handleSchemaItemSelect} + />
{!query.isNew() && (
@@ -378,7 +234,7 @@ function QuerySource(props) { schema={schema} autocompleteEnabled={autocompleteAvailable && autocompleteEnabled} onChange={handleQueryEditorChange} - onSelectionChange={handleSelectionChange} + onSelectionChange={setSelectedText} /> Save @@ -418,7 +274,7 @@ function QuerySource(props) { dataSourceSelectorProps={ dataSource ? { - disabled: !query.can_edit, + disabled: !queryFlags.canEdit, value: dataSource.id, onChange: handleDataSourceChange, options: map(dataSources, ds => ({ value: ds.id, label: ds.name })), @@ -429,7 +285,7 @@ function QuerySource(props) {
- {!query.isNew() && } + {!queryFlags.isNew && }
setDirtyParameters(query.getParameters().hasPendingValues())} + onPendingValuesChange={() => updateParametersDirtyFlag()} onValuesChange={() => { - setDirtyParameters(false); + updateParametersDirtyFlag(false); doExecuteQuery(); }} onParametersEdit={() => { @@ -486,19 +342,12 @@ function QuerySource(props) { - addQueryVisualization(query, queryResult).then(({ query, visualization }) => { - setQuery(query); - setSelectedTab(visualization.id); - }) - } - onDeleteVisualization={visualization => - deleteQueryVisualization(query, visualization).then(setQuery) - } + showNewVisualizationButton={queryFlags.canEdit} + canDeleteVisualizations={queryFlags.canEdit} + selectedTab={selectedVisualization} + onChangeTab={setSelectedVisualization} + onAddVisualization={addVisualization} + onDeleteVisualization={deleteVisualization} />
@@ -510,16 +359,10 @@ function QuerySource(props) { {queryResultData.status === "done" && (
- {!query.isNew() && query.can_edit && ( + {!queryFlags.isNew && queryFlags.canEdit && ( - editQueryVisualization( - query, - queryResult, - find(query.visualizations, { id: visId }) - ).then(({ query }) => setQuery(query)) - } - selectedTab={selectedTab} + openVisualizationEditor={editVisualization} + selectedTab={selectedVisualization} /> )} diff --git a/client/app/pages/queries/QueryView.jsx b/client/app/pages/queries/QueryView.jsx index 4d34ce9705..0436cbc191 100644 --- a/client/app/pages/queries/QueryView.jsx +++ b/client/app/pages/queries/QueryView.jsx @@ -16,7 +16,7 @@ import { EditVisualizationButton } from "@/components/EditVisualizationButton"; import useQueryResult from "@/lib/hooks/useQueryResult"; import { pluralize } from "@/filters"; -import useVisualizationTabHandler from "./utils/useVisualizationTabHandler"; +import useVisualizationTabHandler from "./hooks/useVisualizationTabHandler"; function QueryView({ query }) { const canEdit = useMemo(() => currentUser.canEdit(query) || query.can_edit, [query]); diff --git a/client/app/pages/queries/components/QueryPageHeader.jsx b/client/app/pages/queries/components/QueryPageHeader.jsx index cf43f69ddf..450fa17161 100644 --- a/client/app/pages/queries/components/QueryPageHeader.jsx +++ b/client/app/pages/queries/components/QueryPageHeader.jsx @@ -1,5 +1,5 @@ import { extend, map, filter, reduce } from "lodash"; -import React, { useMemo, useCallback } from "react"; +import React, { useMemo } from "react"; import PropTypes from "prop-types"; import cx from "classnames"; import Button from "antd/lib/button"; @@ -9,11 +9,17 @@ import Icon from "antd/lib/icon"; import { EditInPlace } from "@/components/EditInPlace"; import { FavoritesControl } from "@/components/FavoritesControl"; import { QueryTagsControl } from "@/components/tags-control/TagsControl"; -import PermissionsEditorDialog from "@/components/PermissionsEditorDialog"; -import ApiKeyDialog from "@/components/queries/ApiKeyDialog"; import getTags from "@/services/getTags"; - -import { updateQuery, publishQuery, unpublishQuery, renameQuery, archiveQuery, duplicateQuery } from "../utils"; +import { clientConfig } from "@/services/auth"; +import useQueryFlags from "../hooks/useQueryFlags"; +import useArchiveQuery from "../hooks/useArchiveQuery"; +import usePublishQuery from "../hooks/usePublishQuery"; +import useUnpublishQuery from "../hooks/useUnpublishQuery"; +import useUpdateQueryTags from "../hooks/useUpdateQueryTags"; +import useRenameQuery from "../hooks/useRenameQuery"; +import useDuplicateQuery from "../hooks/useDuplicateQuery"; +import useApiKeyDialog from "../hooks/useApiKeyDialog"; +import usePermissionsEditorDialog from "../hooks/usePermissionsEditorDialog"; function getQueryTags() { return getTags("api/queries/tags").then(tags => map(tags, t => t.name)); @@ -53,133 +59,109 @@ function createMenu(menu) { ); } -export default function QueryPageHeader({ query, sourceMode, onChange }) { - const saveName = useCallback( - name => { - renameQuery(query, name).then(onChange); - }, - [query, onChange] - ); - - const saveTags = useCallback( - tags => { - updateQuery(query, { tags }).then(onChange); - }, - [query, onChange] - ); - - const selectedTab = null; // TODO: replace with actual value - const canViewSource = true; // TODO: replace with actual value - const canForkQuery = () => true; // TODO: replace with actual value - const showPermissionsControl = true; // TODO: replace with actual value +export default function QueryPageHeader({ query, dataSource, sourceMode, selectedVisualization, onChange }) { + const queryFlags = useQueryFlags(query, dataSource); + const updateName = useRenameQuery(query, onChange); + const updateTags = useUpdateQueryTags(query, onChange); + const archiveQuery = useArchiveQuery(query, onChange); + const publishQuery = usePublishQuery(query, onChange); + const unpublishQuery = useUnpublishQuery(query, onChange); + const duplicateQuery = useDuplicateQuery(query); + const openApiKeyDialog = useApiKeyDialog(query, onChange); + const openPermissionsEditorDialog = usePermissionsEditorDialog(query); const moreActionsMenu = useMemo( () => createMenu([ { fork: { - isEnabled: !query.isNew() && canForkQuery(), + isEnabled: !queryFlags.isNew && queryFlags.canFork, title: ( Fork ), - onClick: () => { - duplicateQuery(query); - }, + onClick: duplicateQuery, }, }, { archive: { - isAvailable: !query.isNew() && query.can_edit && !query.is_archived, + isAvailable: !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isArchived, title: "Archive", - onClick: () => { - archiveQuery(query).then(onChange); - }, + onClick: archiveQuery, }, managePermissions: { - isAvailable: !query.isNew() && query.can_edit && !query.is_archived && showPermissionsControl, + isAvailable: + !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isArchived && clientConfig.showPermissionsControl, title: "Manage Permissions", - onClick: () => { - const aclUrl = `api/queries/${query.id}/acl`; - PermissionsEditorDialog.showModal({ - aclUrl, - context: "query", - author: query.user, - }); - }, + onClick: openPermissionsEditorDialog, }, unpublish: { - isAvailable: !query.isNew() && query.can_edit && !query.is_draft, + isAvailable: !queryFlags.isNew && queryFlags.canEdit && !queryFlags.isDraft, title: "Unpublish", - onClick: () => { - unpublishQuery(query).then(onChange); - }, + onClick: unpublishQuery, }, }, { showAPIKey: { - isAvailable: !query.isNew(), + isAvailable: !queryFlags.isNew, title: "Show API Key", - onClick: () => { - ApiKeyDialog.showModal({ query }).result.then(onChange); - }, + onClick: openApiKeyDialog, }, }, ]), - [showPermissionsControl, query, onChange] + [queryFlags, archiveQuery, unpublishQuery, openApiKeyDialog, openPermissionsEditorDialog, duplicateQuery] ); return (
- {!query.isNew() && ( + {!queryFlags.isNew && ( )}

- +

- {query.is_draft && !query.is_archived && !query.isNew() && query.can_edit && ( - )} - {!query.isNew() && canViewSource && ( + {!queryFlags.isNew && queryFlags.canViewSource && ( {!sourceMode && ( -