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 && (
-
@@ -206,17 +188,17 @@ QueryPageHeader.propTypes = {
query: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
- isNew: PropTypes.func,
- can_edit: PropTypes.bool,
- is_draft: PropTypes.bool,
- is_archived: PropTypes.bool,
tags: PropTypes.arrayOf(PropTypes.string),
}).isRequired,
+ dataSource: PropTypes.object,
sourceMode: PropTypes.bool,
+ selectedVisualization: PropTypes.number,
onChange: PropTypes.func,
};
QueryPageHeader.defaultProps = {
+ dataSource: null,
sourceMode: false,
+ selectedVisualization: null,
onChange: () => {},
};
diff --git a/client/app/pages/queries/components/QueryVisualizationTabs.jsx b/client/app/pages/queries/components/QueryVisualizationTabs.jsx
index 8b2c2adbae..7b8dc5725f 100644
--- a/client/app/pages/queries/components/QueryVisualizationTabs.jsx
+++ b/client/app/pages/queries/components/QueryVisualizationTabs.jsx
@@ -7,7 +7,7 @@ import { VisualizationRenderer } from "@/visualizations/VisualizationRenderer";
import Button from "antd/lib/button";
import Modal from "antd/lib/modal";
-import "./query-visualization-tabs.less";
+import "./QueryVisualizationTabs.less";
const { TabPane } = Tabs;
@@ -62,7 +62,7 @@ export default function QueryVisualizationTabs({
showNewVisualizationButton,
canDeleteVisualizations,
onChangeTab,
- onClickNewVisualization,
+ onAddVisualization,
onDeleteVisualization,
...props
}) {
@@ -78,7 +78,7 @@ export default function QueryVisualizationTabs({
if (showNewVisualizationButton) {
tabsProps.tabBarExtraContent = (
-
+ onAddVisualization()}>
New Visualization
@@ -104,7 +104,7 @@ export default function QueryVisualizationTabs({
onDeleteVisualization(visualization)}
+ onDelete={() => onDeleteVisualization(visualization.id)}
/>
}>
@@ -121,7 +121,7 @@ QueryVisualizationTabs.propTypes = {
showNewVisualizationButton: PropTypes.bool,
canDeleteVisualizations: PropTypes.bool,
onChangeTab: PropTypes.func,
- onClickNewVisualization: PropTypes.func,
+ onAddVisualization: PropTypes.func,
onDeleteVisualization: PropTypes.func,
};
@@ -132,6 +132,6 @@ QueryVisualizationTabs.defaultProps = {
showNewVisualizationButton: false,
canDeleteVisualizations: false,
onChangeTab: () => {},
- onClickNewVisualization: () => {},
+ onAddVisualization: () => {},
onDeleteVisualization: () => {},
};
diff --git a/client/app/pages/queries/components/query-visualization-tabs.less b/client/app/pages/queries/components/QueryVisualizationTabs.less
similarity index 100%
rename from client/app/pages/queries/components/query-visualization-tabs.less
rename to client/app/pages/queries/components/QueryVisualizationTabs.less
diff --git a/client/app/pages/queries/hooks/useAddNewParameterDialog.js b/client/app/pages/queries/hooks/useAddNewParameterDialog.js
new file mode 100644
index 0000000000..234d0f5fef
--- /dev/null
+++ b/client/app/pages/queries/hooks/useAddNewParameterDialog.js
@@ -0,0 +1,24 @@
+import { isFunction, map } from "lodash";
+import { useCallback, useRef } from "react";
+import EditParameterSettingsDialog from "@/components/EditParameterSettingsDialog";
+
+export default function useAddNewParameterDialog(query, onParameterAdded) {
+ const onParameterAddedRef = useRef();
+ onParameterAddedRef.current = isFunction(onParameterAdded) ? onParameterAdded : () => {};
+
+ return 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);
+ onParameterAddedRef.current(newQuery, param);
+ });
+ }, [query]);
+}
diff --git a/client/app/pages/queries/hooks/useAddToDashboardDialog.js b/client/app/pages/queries/hooks/useAddToDashboardDialog.js
new file mode 100644
index 0000000000..cc1ff56360
--- /dev/null
+++ b/client/app/pages/queries/hooks/useAddToDashboardDialog.js
@@ -0,0 +1,13 @@
+import { find } from "lodash";
+import { useCallback } from "react";
+import AddToDashboardDialog from "@/components/queries/AddToDashboardDialog";
+
+export default function useAddToDashboardDialog(query) {
+ return useCallback(
+ visualizationId => {
+ const visualization = find(query.visualizations, { id: visualizationId });
+ AddToDashboardDialog.showModal({ visualization });
+ },
+ [query.visualizations]
+ );
+}
diff --git a/client/app/pages/queries/hooks/useApiKeyDialog.js b/client/app/pages/queries/hooks/useApiKeyDialog.js
new file mode 100644
index 0000000000..aff8e838b5
--- /dev/null
+++ b/client/app/pages/queries/hooks/useApiKeyDialog.js
@@ -0,0 +1,14 @@
+import { isFunction } from "lodash";
+import { useRef, useCallback } from "react";
+import ApiKeyDialog from "@/components/queries/ApiKeyDialog";
+
+export default function useApiKeyDialog(query, onChange) {
+ const onChangeRef = useRef();
+ onChangeRef.current = isFunction(onChange) ? onChange : () => {};
+
+ return useCallback(() => {
+ ApiKeyDialog.showModal({ query }).result.then(updatedQuery => {
+ onChangeRef.current(updatedQuery);
+ });
+ }, [query]);
+}
diff --git a/client/app/pages/queries/utils/archiveQuery.jsx b/client/app/pages/queries/hooks/useArchiveQuery.jsx
similarity index 65%
rename from client/app/pages/queries/utils/archiveQuery.jsx
rename to client/app/pages/queries/hooks/useArchiveQuery.jsx
index 14c32673a7..dd8e9d9992 100644
--- a/client/app/pages/queries/utils/archiveQuery.jsx
+++ b/client/app/pages/queries/hooks/useArchiveQuery.jsx
@@ -1,5 +1,5 @@
-import { extend } from "lodash";
-import React from "react";
+import { extend, isFunction } from "lodash";
+import React, { useCallback, useRef } from "react";
import Modal from "antd/lib/modal";
import { Query } from "@/services/query";
import notification from "@/services/notification";
@@ -29,10 +29,8 @@ function confirmArchive() {
}
function doArchiveQuery(query) {
- // Prettier will put `.$promise` before `.catch` on next line :facepalm:
- // prettier-ignore
- return Query.delete({ id: query.id }).$promise
- .then(() => {
+ return Query.delete({ id: query.id })
+ .$promise.then(() => {
return extend(query.clone(), { is_archived: true, schedule: null });
})
.catch(error => {
@@ -41,6 +39,15 @@ function doArchiveQuery(query) {
});
}
-export default function archiveQuery(query) {
- return confirmArchive().then(() => doArchiveQuery(query));
+export default function useArchiveQuery(query, onChange) {
+ const onChangeRef = useRef();
+ onChangeRef.current = isFunction(onChange) ? onChange : () => {};
+
+ return useCallback(() => {
+ confirmArchive()
+ .then(() => doArchiveQuery(query))
+ .then(updatedQuery => {
+ onChangeRef.current(updatedQuery);
+ });
+ }, [query]);
}
diff --git a/client/app/pages/queries/hooks/useAutocompleteFlags.js b/client/app/pages/queries/hooks/useAutocompleteFlags.js
new file mode 100644
index 0000000000..9d94e5412f
--- /dev/null
+++ b/client/app/pages/queries/hooks/useAutocompleteFlags.js
@@ -0,0 +1,14 @@
+import { useCallback, useMemo, useState } from "react";
+import localOptions from "@/lib/localOptions";
+
+export default function useAutocompleteFlags(schema) {
+ const isAvailable = useMemo(() => schema.tokensCount <= 5000, [schema]);
+ const [isEnabled, setIsEnabled] = useState(localOptions.get("liveAutocomplete", true));
+
+ const toggleAutocomplete = useCallback(state => {
+ setIsEnabled(state);
+ localOptions.set("liveAutocomplete", state);
+ }, []);
+
+ return useMemo(() => [isAvailable, isEnabled, toggleAutocomplete], [isAvailable, isEnabled, toggleAutocomplete]);
+}
diff --git a/client/app/pages/queries/hooks/useDataSourceSchema.js b/client/app/pages/queries/hooks/useDataSourceSchema.js
new file mode 100644
index 0000000000..e58e133e67
--- /dev/null
+++ b/client/app/pages/queries/hooks/useDataSourceSchema.js
@@ -0,0 +1,63 @@
+import { reduce } from "lodash";
+import { useCallback, useEffect, useRef, useState, useMemo } from "react";
+import { SCHEMA_NOT_SUPPORTED } from "@/services/data-source";
+import notification from "@/services/notification";
+
+function getSchema(dataSource, refresh = undefined) {
+ if (!dataSource) {
+ return Promise.resolve([]);
+ }
+
+ return dataSource
+ .getSchema(refresh)
+ .then(data => {
+ 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([]);
+ });
+}
+
+function prepareSchema(schema) {
+ schema.tokensCount = reduce(schema, (totalLength, table) => totalLength + table.columns.length, 0);
+ return schema;
+}
+
+export default function useDataSourceSchema(dataSource) {
+ const [schema, setSchema] = useState(prepareSchema([]));
+ const refreshSchemaTokenRef = useRef(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(prepareSchema(data));
+ }
+ });
+ },
+ [dataSource]
+ );
+
+ useEffect(() => {
+ reloadSchema();
+ }, [reloadSchema]);
+
+ useEffect(() => {
+ return () => {
+ // cancel pending operations
+ refreshSchemaTokenRef.current = null;
+ };
+ }, []);
+
+ return useMemo(() => [schema, reloadSchema], [schema, reloadSchema]);
+}
diff --git a/client/app/pages/queries/hooks/useDeleteVisualization.js b/client/app/pages/queries/hooks/useDeleteVisualization.js
new file mode 100644
index 0000000000..f98ed184eb
--- /dev/null
+++ b/client/app/pages/queries/hooks/useDeleteVisualization.js
@@ -0,0 +1,22 @@
+import { extend, filter, isFunction } from "lodash";
+import { useRef, useCallback } from "react";
+import { Visualization } from "@/services/visualization";
+import notification from "@/services/notification";
+
+export default function useDeleteVisualization(query, onChange) {
+ const onChangeRef = useRef();
+ onChangeRef.current = isFunction(onChange) ? onChange : () => {};
+
+ return useCallback(
+ visualizationId =>
+ Visualization.delete({ id: visualizationId })
+ .$promise.then(() => {
+ const filteredVisualizations = filter(query.visualizations, v => v.id !== visualizationId);
+ onChangeRef.current(extend(query.clone(), { visualizations: filteredVisualizations }));
+ })
+ .catch(() => {
+ notification.error("Error deleting visualization.", "Maybe it's used in a dashboard?");
+ }),
+ [query]
+ );
+}
diff --git a/client/app/pages/queries/hooks/useDuplicateQuery.js b/client/app/pages/queries/hooks/useDuplicateQuery.js
new file mode 100644
index 0000000000..2ab9569597
--- /dev/null
+++ b/client/app/pages/queries/hooks/useDuplicateQuery.js
@@ -0,0 +1,20 @@
+import { useCallback } from "react";
+import { Query } from "@/services/query";
+
+export default function useDuplicateQuery(query) {
+ return useCallback(() => {
+ // To prevent opening the same tab, name must be unique for each browser
+ const tabName = `duplicatedQueryTab/${Math.random().toString()}`;
+
+ // We should open tab here because this moment is a part of user interaction;
+ // later browser will block such attempts
+ const tab = window.open("", tabName);
+
+ // Prettier will put `.$promise` before `.catch` on next line :facepalm:
+ // prettier-ignore
+ Query.fork({ id: query.id }).$promise
+ .then(newQuery => {
+ tab.location = newQuery.getUrl(true);
+ });
+ }, [query.id]);
+}
diff --git a/client/app/pages/queries/hooks/useEditScheduleDialog.js b/client/app/pages/queries/hooks/useEditScheduleDialog.js
new file mode 100644
index 0000000000..897c3dd3e0
--- /dev/null
+++ b/client/app/pages/queries/hooks/useEditScheduleDialog.js
@@ -0,0 +1,33 @@
+import { isArray, intersection } from "lodash";
+import { useCallback } from "react";
+import ScheduleDialog from "@/components/queries/ScheduleDialog";
+import { clientConfig } from "@/services/auth";
+import { policy } from "@/services/policy";
+import useUpdateQuery from "./useUpdateQuery";
+import useQueryFlags from "./useQueryFlags";
+import recordEvent from "@/services/recordEvent";
+
+export default function useEditScheduleDialog(query, onChange) {
+ // We won't use flags that depend on data source
+ const queryFlags = useQueryFlags(query);
+
+ const updateQuery = useUpdateQuery(query, onChange);
+
+ return useCallback(() => {
+ if (!queryFlags.canEdit || !queryFlags.canSchedule) {
+ return;
+ }
+
+ 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 => {
+ recordEvent("edit_schedule", "query", query.id);
+ updateQuery({ schedule });
+ });
+ }, [query.id, query.schedule, queryFlags.canEdit, queryFlags.canSchedule, updateQuery]);
+}
diff --git a/client/app/pages/queries/hooks/useEditVisualizationDialog.js b/client/app/pages/queries/hooks/useEditVisualizationDialog.js
new file mode 100644
index 0000000000..d5e2259654
--- /dev/null
+++ b/client/app/pages/queries/hooks/useEditVisualizationDialog.js
@@ -0,0 +1,26 @@
+import { isFunction, extend, filter, find } from "lodash";
+import { useCallback, useRef } from "react";
+import EditVisualizationDialog from "@/visualizations/EditVisualizationDialog";
+
+export default function useEditVisualizationDialog(query, queryResult, onChange) {
+ const onChangeRef = useRef();
+ onChangeRef.current = isFunction(onChange) ? onChange : () => {};
+
+ return useCallback(
+ (visualizationId = null) => {
+ const visualization = find(query.visualizations, { id: visualizationId }) || null;
+ EditVisualizationDialog.showModal({
+ query,
+ visualization,
+ queryResult,
+ }).result.then(updatedVisualization => {
+ const filteredVisualizations = filter(query.visualizations, v => v.id !== updatedVisualization.id);
+ onChangeRef.current(
+ extend(query.clone(), { visualizations: [...filteredVisualizations, updatedVisualization] }),
+ updatedVisualization
+ );
+ });
+ },
+ [query, queryResult]
+ );
+}
diff --git a/client/app/pages/queries/hooks/useEmbedDialog.js b/client/app/pages/queries/hooks/useEmbedDialog.js
new file mode 100644
index 0000000000..8108229f2b
--- /dev/null
+++ b/client/app/pages/queries/hooks/useEmbedDialog.js
@@ -0,0 +1,13 @@
+import { find } from "lodash";
+import { useCallback } from "react";
+import EmbedQueryDialog from "@/components/queries/EmbedQueryDialog";
+
+export default function useEmbedDialog(query) {
+ return useCallback(
+ (unusedQuery, visualizationId) => {
+ const visualization = find(query.visualizations, { id: visualizationId });
+ EmbedQueryDialog.showModal({ query, visualization });
+ },
+ [query]
+ );
+}
diff --git a/client/app/pages/queries/hooks/useFormatQuery.js b/client/app/pages/queries/hooks/useFormatQuery.js
new file mode 100644
index 0000000000..53a7970dfd
--- /dev/null
+++ b/client/app/pages/queries/hooks/useFormatQuery.js
@@ -0,0 +1,17 @@
+import { extend, isFunction } from "lodash";
+import { useCallback, useRef } from "react";
+import { Query } from "@/services/query";
+import notification from "@/services/notification";
+
+export default function useFormatQuery(query, syntax, onChange) {
+ const onChangeRef = useRef();
+ onChangeRef.current = isFunction(onChange) ? onChange : () => {};
+
+ return useCallback(() => {
+ Query.format(syntax || "sql", query.query)
+ .then(queryText => {
+ onChangeRef.current(extend(query.clone(), { query: queryText }));
+ })
+ .catch(error => notification.error(error));
+ }, [query, syntax]);
+}
diff --git a/client/app/pages/queries/hooks/usePermissionsEditorDialog.js b/client/app/pages/queries/hooks/usePermissionsEditorDialog.js
new file mode 100644
index 0000000000..ab6586fdcf
--- /dev/null
+++ b/client/app/pages/queries/hooks/usePermissionsEditorDialog.js
@@ -0,0 +1,12 @@
+import { useCallback } from "react";
+import PermissionsEditorDialog from "@/components/PermissionsEditorDialog";
+
+export default function usePermissionsEditorDialog(query) {
+ return useCallback(() => {
+ PermissionsEditorDialog.showModal({
+ aclUrl: `api/queries/${query.id}/acl`,
+ context: "query",
+ author: query.user,
+ });
+ }, [query.id, query.user]);
+}
diff --git a/client/app/pages/queries/hooks/usePublishQuery.js b/client/app/pages/queries/hooks/usePublishQuery.js
new file mode 100644
index 0000000000..b21f02814c
--- /dev/null
+++ b/client/app/pages/queries/hooks/usePublishQuery.js
@@ -0,0 +1,12 @@
+import { useCallback } from "react";
+import useUpdateQuery from "./useUpdateQuery";
+import recordEvent from "@/services/recordEvent";
+
+export default function usePublishQuery(query, onChange) {
+ const updateQuery = useUpdateQuery(query, onChange);
+
+ return useCallback(() => {
+ recordEvent("toggle_published", "query", query.id);
+ updateQuery({ is_draft: false });
+ }, [query.id, updateQuery]);
+}
diff --git a/client/app/pages/queries/hooks/useQuery.js b/client/app/pages/queries/hooks/useQuery.js
new file mode 100644
index 0000000000..a13f83f9d4
--- /dev/null
+++ b/client/app/pages/queries/hooks/useQuery.js
@@ -0,0 +1,26 @@
+import { useState, useMemo } from "react";
+import useUpdateQuery from "./useUpdateQuery";
+import navigateTo from "@/services/navigateTo";
+
+export default function useQuery(originalQuery) {
+ const [query, setQuery] = useState(originalQuery);
+ const [originalQuerySource, setOriginalQuerySource] = useState(originalQuery.query);
+
+ const updateQuery = useUpdateQuery(query, updatedQuery => {
+ setQuery(updatedQuery);
+ setOriginalQuerySource(updatedQuery.query);
+ if (updatedQuery.id !== query.id) {
+ navigateTo(updatedQuery.getSourceLink());
+ }
+ });
+
+ return useMemo(
+ () => ({
+ query,
+ setQuery,
+ isDirty: query.query !== originalQuerySource,
+ saveQuery: () => updateQuery(),
+ }),
+ [query, originalQuerySource, updateQuery]
+ );
+}
diff --git a/client/app/pages/queries/hooks/useQueryDataSources.js b/client/app/pages/queries/hooks/useQueryDataSources.js
new file mode 100644
index 0000000000..0603ccecb1
--- /dev/null
+++ b/client/app/pages/queries/hooks/useQueryDataSources.js
@@ -0,0 +1,32 @@
+import { filter, find } from "lodash";
+import { useState, useMemo, useEffect } from "react";
+import { DataSource } from "@/services/data-source";
+
+export default function useQueryDataSources(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.data_source_id,
+ dataSources,
+ ]);
+
+ useEffect(() => {
+ let cancelDataSourceLoading = false;
+ DataSource.query().$promise.then(data => {
+ if (!cancelDataSourceLoading) {
+ setDataSourcesLoaded(true);
+ setAllDataSources(data);
+ }
+ });
+
+ return () => {
+ cancelDataSourceLoading = true;
+ };
+ }, []);
+
+ return useMemo(() => ({ dataSourcesLoaded, dataSources, dataSource }), [dataSourcesLoaded, dataSources, dataSource]);
+}
diff --git a/client/app/pages/queries/utils/useQueryExecute.js b/client/app/pages/queries/hooks/useQueryExecute.js
similarity index 82%
rename from client/app/pages/queries/utils/useQueryExecute.js
rename to client/app/pages/queries/hooks/useQueryExecute.js
index d28bccbe10..8bf124405a 100644
--- a/client/app/pages/queries/utils/useQueryExecute.js
+++ b/client/app/pages/queries/hooks/useQueryExecute.js
@@ -44,7 +44,7 @@ export default function useQueryExecute(query) {
queryResult.cancelExecution();
setIsExecutionCancelling(true);
}
- }, [query, queryResult]);
+ }, [query.id, queryResult]);
useEffect(() => {
if (!isQueryExecuting) {
@@ -56,13 +56,24 @@ export default function useQueryExecute(query) {
}
}, [isQueryExecuting]); // eslint-disable-line react-hooks/exhaustive-deps
- return {
- queryResult,
- queryResultData,
- isQueryExecuting,
- isExecutionCancelling,
- executeQuery,
- executeAdhocQuery,
- cancelExecution,
- };
+ return useMemo(
+ () => ({
+ queryResult,
+ queryResultData,
+ isQueryExecuting,
+ isExecutionCancelling,
+ executeQuery,
+ executeAdhocQuery,
+ cancelExecution,
+ }),
+ [
+ queryResult,
+ queryResultData,
+ isQueryExecuting,
+ isExecutionCancelling,
+ executeQuery,
+ executeAdhocQuery,
+ cancelExecution,
+ ]
+ );
}
diff --git a/client/app/pages/queries/hooks/useQueryFlags.js b/client/app/pages/queries/hooks/useQueryFlags.js
new file mode 100644
index 0000000000..42f9261a33
--- /dev/null
+++ b/client/app/pages/queries/hooks/useQueryFlags.js
@@ -0,0 +1,27 @@
+import { isNil, isEmpty } from "lodash";
+import { useMemo } from "react";
+import { currentUser } from "@/services/auth";
+
+export default function useQueryFlags(query, dataSource = null) {
+ dataSource = dataSource || { view_only: true };
+
+ return useMemo(
+ () => ({
+ // state flags
+ isNew: isNil(query.id),
+ isDraft: query.is_draft,
+ isArchived: query.is_archived,
+
+ // permissions flags
+ canEdit: query.can_edit,
+ canViewSource: currentUser.hasPermission("view_source"),
+ canExecute:
+ !isEmpty(query.query) &&
+ !query.getParameters().hasPendingValues() &&
+ (query.is_safe || (currentUser.hasPermission("execute_query") && !dataSource.view_only)),
+ canFork: currentUser.hasPermission("edit_query") && !dataSource.view_only,
+ canSchedule: currentUser.hasPermission("schedule_query"),
+ }),
+ [query, dataSource.view_only]
+ );
+}
diff --git a/client/app/pages/queries/hooks/useQueryParameters.js b/client/app/pages/queries/hooks/useQueryParameters.js
new file mode 100644
index 0000000000..569da78fc4
--- /dev/null
+++ b/client/app/pages/queries/hooks/useQueryParameters.js
@@ -0,0 +1,24 @@
+import { isUndefined } from "lodash";
+import { useEffect, useMemo, useState, useCallback } from "react";
+
+export default function useQueryParameters(query) {
+ const parameters = useMemo(() => query.getParametersDefs(), [query]);
+ const [dirtyFlag, setDirtyFlag] = useState(query.getParameters().hasPendingValues());
+
+ const updateDirtyFlag = useCallback(
+ flag => {
+ flag = isUndefined(flag) ? query.getParameters().hasPendingValues() : flag;
+ setDirtyFlag(flag);
+ },
+ [query]
+ );
+
+ useEffect(() => {
+ const updatedDirtyParameters = query.getParameters().hasPendingValues();
+ if (updatedDirtyParameters !== dirtyFlag) {
+ setDirtyFlag(updatedDirtyParameters);
+ }
+ }, [query, parameters, dirtyFlag]);
+
+ return useMemo(() => [parameters, dirtyFlag, updateDirtyFlag], [parameters, dirtyFlag, updateDirtyFlag]);
+}
diff --git a/client/app/pages/queries/hooks/useRenameQuery.js b/client/app/pages/queries/hooks/useRenameQuery.js
new file mode 100644
index 0000000000..ca95f19f49
--- /dev/null
+++ b/client/app/pages/queries/hooks/useRenameQuery.js
@@ -0,0 +1,24 @@
+import { useCallback } from "react";
+import useUpdateQuery from "./useUpdateQuery";
+import recordEvent from "@/services/recordEvent";
+import { clientConfig } from "@/services/auth";
+
+export default function useRenameQuery(query, onChange) {
+ const updateQuery = useUpdateQuery(query, onChange);
+
+ return useCallback(
+ name => {
+ recordEvent("edit_name", "query", query.id);
+ const changes = { name };
+ const options = {};
+
+ if (query.is_draft && clientConfig.autoPublishNamedQueries && name !== "New Query") {
+ changes.is_draft = false;
+ options.successMessage = "Query saved and published";
+ }
+
+ updateQuery(changes, options);
+ },
+ [query.id, query.is_draft, updateQuery]
+ );
+}
diff --git a/client/app/pages/queries/hooks/useUnpublishQuery.js b/client/app/pages/queries/hooks/useUnpublishQuery.js
new file mode 100644
index 0000000000..9f7995e634
--- /dev/null
+++ b/client/app/pages/queries/hooks/useUnpublishQuery.js
@@ -0,0 +1,12 @@
+import { useCallback } from "react";
+import useUpdateQuery from "./useUpdateQuery";
+import recordEvent from "@/services/recordEvent";
+
+export default function useUnpublishQuery(query, onChange) {
+ const updateQuery = useUpdateQuery(query, onChange);
+
+ return useCallback(() => {
+ recordEvent("toggle_published", "query", query.id);
+ updateQuery({ is_draft: true });
+ }, [query.id, updateQuery]);
+}
diff --git a/client/app/pages/queries/utils/updateQuery.jsx b/client/app/pages/queries/hooks/useUpdateQuery.jsx
similarity index 57%
rename from client/app/pages/queries/utils/updateQuery.jsx
rename to client/app/pages/queries/hooks/useUpdateQuery.jsx
index f3565bc4b2..10a427c1ca 100644
--- a/client/app/pages/queries/utils/updateQuery.jsx
+++ b/client/app/pages/queries/hooks/useUpdateQuery.jsx
@@ -1,5 +1,5 @@
-import { isNil, isObject, extend, keys, map, omit, pick, uniq } from "lodash";
-import React from "react";
+import { isNil, isObject, extend, keys, map, omit, pick, uniq, isFunction } from "lodash";
+import React, { useRef, useCallback } from "react";
import Modal from "antd/lib/modal";
import { Query } from "@/services/query";
import notification from "@/services/notification";
@@ -72,41 +72,49 @@ function doSaveQuery(data, { canOverwrite = false } = {}) {
});
}
-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(query.clone(), data));
- }
- data = { ...data, id: query.id, version: query.version };
- } else {
- data = pick(query, [
- "id",
- "version",
- "schedule",
- "query",
- "description",
- "name",
- "data_source_id",
- "options",
- "latest_query_data_id",
- "is_draft",
- ]);
- }
+export default function useUpdateQuery(query, onChange) {
+ const onChangeRef = useRef();
+ onChangeRef.current = isFunction(onChange) ? onChange : () => {};
- return doSaveQuery(data, { canOverwrite: query.can_edit })
- .then(updatedQuery => {
- if (!isNil(successMessage)) {
- notification.success(successMessage);
- }
- return extend(query.clone(), pick(updatedQuery, uniq(["id", "version", ...keys(data)])));
- })
- .catch(error => {
- const notificationOptions = {};
- if (error instanceof SaveQueryConflictError) {
- notificationOptions.duration = null;
+ return useCallback(
+ (data = null, { successMessage = "Query saved" } = {}) => {
+ if (isObject(data)) {
+ // Don't save new query with partial data
+ if (query.isNew()) {
+ onChangeRef.current(extend(query.clone(), data));
+ return;
+ }
+ data = { ...data, id: query.id, version: query.version };
+ } else {
+ data = pick(query, [
+ "id",
+ "version",
+ "schedule",
+ "query",
+ "description",
+ "name",
+ "data_source_id",
+ "options",
+ "latest_query_data_id",
+ "is_draft",
+ ]);
}
- notification.error(error.message, error.detailedMessage, notificationOptions);
- return Promise.reject(error);
- });
+
+ return doSaveQuery(data, { canOverwrite: query.can_edit })
+ .then(updatedQuery => {
+ if (!isNil(successMessage)) {
+ notification.success(successMessage);
+ }
+ onChangeRef.current(extend(query.clone(), pick(updatedQuery, uniq(["id", "version", ...keys(data)]))));
+ })
+ .catch(error => {
+ const notificationOptions = {};
+ if (error instanceof SaveQueryConflictError) {
+ notificationOptions.duration = null;
+ }
+ notification.error(error.message, error.detailedMessage, notificationOptions);
+ });
+ },
+ [query]
+ );
}
diff --git a/client/app/pages/queries/hooks/useUpdateQueryDescription.js b/client/app/pages/queries/hooks/useUpdateQueryDescription.js
new file mode 100644
index 0000000000..577ba1a4d3
--- /dev/null
+++ b/client/app/pages/queries/hooks/useUpdateQueryDescription.js
@@ -0,0 +1,15 @@
+import { useCallback } from "react";
+import useUpdateQuery from "./useUpdateQuery";
+import recordEvent from "@/services/recordEvent";
+
+export default function useUpdateQueryDescription(query, onChange) {
+ const updateQuery = useUpdateQuery(query, onChange);
+
+ return useCallback(
+ description => {
+ recordEvent("edit_description", "query", query.id);
+ updateQuery({ description });
+ },
+ [query.id, updateQuery]
+ );
+}
diff --git a/client/app/pages/queries/hooks/useUpdateQueryTags.js b/client/app/pages/queries/hooks/useUpdateQueryTags.js
new file mode 100644
index 0000000000..6c6d45ce59
--- /dev/null
+++ b/client/app/pages/queries/hooks/useUpdateQueryTags.js
@@ -0,0 +1,15 @@
+import { useCallback } from "react";
+import useUpdateQuery from "./useUpdateQuery";
+import recordEvent from "@/services/recordEvent";
+
+export default function useUpdateQueryTags(query, onChange) {
+ const updateQuery = useUpdateQuery(query, onChange);
+
+ return useCallback(
+ tags => {
+ recordEvent("edit_tags", "query", query.id);
+ updateQuery({ tags });
+ },
+ [query.id, updateQuery]
+ );
+}
diff --git a/client/app/pages/queries/utils/useVisualizationTabHandler.js b/client/app/pages/queries/hooks/useVisualizationTabHandler.js
similarity index 94%
rename from client/app/pages/queries/utils/useVisualizationTabHandler.js
rename to client/app/pages/queries/hooks/useVisualizationTabHandler.js
index 23b18e0205..2230d64a67 100644
--- a/client/app/pages/queries/utils/useVisualizationTabHandler.js
+++ b/client/app/pages/queries/hooks/useVisualizationTabHandler.js
@@ -35,5 +35,5 @@ export default function useVisualizationTabHandler(visualizations) {
}
}, [firstVisualization.id, selectedTab, visualizations]);
- return [selectedTab, setSelectedTab];
+ return useMemo(() => [selectedTab, setSelectedTab], [selectedTab]);
}
diff --git a/client/app/pages/queries/utils/deleteQueryVisualization.js b/client/app/pages/queries/utils/deleteQueryVisualization.js
deleted file mode 100644
index 438f5000b5..0000000000
--- a/client/app/pages/queries/utils/deleteQueryVisualization.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { clone, extend, filter } from "lodash";
-import { Visualization } from "@/services/visualization";
-import notification from "@/services/notification";
-
-export default function deleteQueryVisualization(query, visualization) {
- return Visualization.delete({ id: visualization.id })
- .$promise.then(() => {
- const filteredVisualizations = filter(query.visualizations, v => v.id !== visualization.id);
- return Promise.resolve(extend(clone(query), { visualizations: filteredVisualizations }));
- })
- .catch(() => {
- notification.error("Error deleting visualization.", "Maybe it's used in a dashboard?");
- return Promise.reject();
- });
-}
diff --git a/client/app/pages/queries/utils/duplicateQuery.js b/client/app/pages/queries/utils/duplicateQuery.js
deleted file mode 100644
index 1714479864..0000000000
--- a/client/app/pages/queries/utils/duplicateQuery.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Query } from "@/services/query";
-
-export default function duplicateQuery(query) {
- // To prevent opening the same tab, name must be unique for each browser
- const tabName = `duplicatedQueryTab/${Math.random().toString()}`;
-
- // We should open tab here because this moment is a part of user interaction;
- // later browser will block such attempts
- const tab = window.open("", tabName);
-
- // Prettier will put `.$promise` before `.catch` on next line :facepalm:
- // prettier-ignore
- Query.fork({ id: query.id }).$promise
- .then(newQuery => {
- tab.location = newQuery.getUrl(true);
- return newQuery;
- });
-}
diff --git a/client/app/pages/queries/utils/editQueryVisualization.js b/client/app/pages/queries/utils/editQueryVisualization.js
deleted file mode 100644
index c3a57e662c..0000000000
--- a/client/app/pages/queries/utils/editQueryVisualization.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import { clone, extend, filter } from "lodash";
-import EditVisualizationDialog from "@/visualizations/EditVisualizationDialog";
-
-export function editQueryVisualization(query, queryResult, visualization) {
- return EditVisualizationDialog.showModal({
- query,
- visualization,
- queryResult,
- }).result.then(updatedVisualization => {
- const filteredVisualizations = filter(query.visualizations, v => v.id !== updatedVisualization.id);
- return Promise.resolve({
- query: extend(clone(query), { visualizations: [...filteredVisualizations, updatedVisualization] }),
- visualization: updatedVisualization,
- });
- });
-}
-
-export function addQueryVisualization(query, queryResult) {
- return editQueryVisualization(query, queryResult, null);
-}
diff --git a/client/app/pages/queries/utils/index.js b/client/app/pages/queries/utils/index.js
deleted file mode 100644
index 069dd52dae..0000000000
--- a/client/app/pages/queries/utils/index.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import updateQuery from "./updateQuery";
-import archiveQuery from "./archiveQuery";
-import duplicateQuery from "./duplicateQuery";
-import deleteQueryVisualization from "./deleteQueryVisualization";
-import { addQueryVisualization, editQueryVisualization } from "./editQueryVisualization";
-import { clientConfig } from "@/services/auth";
-import recordEvent from "@/services/recordEvent";
-
-function publishQuery(query) {
- recordEvent("toggle_published", "query", query.id);
- return updateQuery(query, { is_draft: false });
-}
-
-function unpublishQuery(query) {
- recordEvent("toggle_published", "query", query.id);
- return updateQuery(query, { is_draft: true });
-}
-
-function renameQuery(query, name) {
- recordEvent("edit_name", "query", query.id);
- const changes = { name };
- const options = {};
-
- if (query.is_draft && clientConfig.autoPublishNamedQueries && query.name !== "New Query") {
- changes.is_draft = false;
- options.successMessage = "Query saved and published";
- }
-
- return updateQuery(query, changes, options);
-}
-
-function updateQueryDescription(query, description) {
- recordEvent("edit_description", "query", query.id);
- return updateQuery(query, { description });
-}
-
-function updateQuerySchedule(query, schedule) {
- recordEvent("edit_schedule", "query", query.id);
- return updateQuery(query, { schedule });
-}
-
-export {
- updateQuery,
- archiveQuery,
- duplicateQuery,
- publishQuery,
- unpublishQuery,
- renameQuery,
- updateQueryDescription,
- updateQuerySchedule,
- deleteQueryVisualization,
- addQueryVisualization,
- editQueryVisualization,
-};