client/app/pages/queries/QuerySource.jsx (412 lines of code) (raw):
import { extend, find, includes, isEmpty, map } from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import { useDebouncedCallback } from "use-debounce";
import useMedia from "use-media";
import Button from "antd/lib/button";
import routeWithUserSession from "@/components/ApplicationArea/routeWithUserSession";
import Resizable from "@/components/Resizable";
import Parameters from "@/components/Parameters";
import EditInPlace from "@/components/EditInPlace";
import DynamicComponent from "@/components/DynamicComponent";
import recordEvent from "@/services/recordEvent";
import { ExecutionStatus } from "@/services/query-result";
import routes from "@/services/routes";
import notification from "@/services/notification";
import * as queryFormat from "@/lib/queryFormat";
import QueryPageHeader from "./components/QueryPageHeader";
import QueryMetadata from "./components/QueryMetadata";
import QueryVisualizationTabs from "./components/QueryVisualizationTabs";
import QueryExecutionStatus from "./components/QueryExecutionStatus";
import QuerySourceAlerts from "./components/QuerySourceAlerts";
import wrapQueryPage from "./components/wrapQueryPage";
import QueryExecutionMetadata from "./components/QueryExecutionMetadata";
import { getEditorComponents } from "@/components/queries/editor-components";
import useQuery from "./hooks/useQuery";
import useVisualizationTabHandler from "./hooks/useVisualizationTabHandler";
import useAutocompleteFlags from "./hooks/useAutocompleteFlags";
import useAutoLimitFlags from "./hooks/useAutoLimitFlags";
import useQueryExecute from "./hooks/useQueryExecute";
import useQueryResultData from "@/lib/useQueryResultData";
import useQueryDataSources from "./hooks/useQueryDataSources";
import useQueryFlags from "./hooks/useQueryFlags";
import useQueryParameters from "./hooks/useQueryParameters";
import useAddNewParameterDialog from "./hooks/useAddNewParameterDialog";
import useEditScheduleDialog from "./hooks/useEditScheduleDialog";
import useAddVisualizationDialog from "./hooks/useAddVisualizationDialog";
import useEditVisualizationDialog from "./hooks/useEditVisualizationDialog";
import useDeleteVisualization from "./hooks/useDeleteVisualization";
import useUpdateQuery from "./hooks/useUpdateQuery";
import useUpdateQueryDescription from "./hooks/useUpdateQueryDescription";
import useUnsavedChangesAlert from "./hooks/useUnsavedChangesAlert";
import "./components/QuerySourceDropdown"; // register QuerySourceDropdown
import "./QuerySource.less";
function chooseDataSourceId(dataSourceIds, availableDataSources) {
availableDataSources = map(availableDataSources, ds => ds.id);
return find(dataSourceIds, id => includes(availableDataSources, id)) || null;
}
function QuerySource(props) {
const { query, setQuery, isDirty, saveQuery } = useQuery(props.query);
const { dataSourcesLoaded, dataSources, dataSource } = useQueryDataSources(query);
const [schema, setSchema] = useState([]);
const queryFlags = useQueryFlags(query, dataSource);
const [parameters, areParametersDirty, updateParametersDirtyFlag] = useQueryParameters(query);
const [selectedVisualization, setSelectedVisualization] = useVisualizationTabHandler(query.visualizations);
const { QueryEditor, SchemaBrowser } = getEditorComponents(dataSource && dataSource.type);
const isMobile = !useMedia({ minWidth: 768 });
useUnsavedChangesAlert(isDirty);
const {
queryResult,
isExecuting: isQueryExecuting,
executionStatus,
executeQuery,
error: executionError,
cancelCallback: cancelExecution,
isCancelling: isExecutionCancelling,
updatedAt,
loadedInitialResults,
} = useQueryExecute(query);
const queryResultData = useQueryResultData(queryResult);
const editorRef = useRef(null);
const [autocompleteAvailable, autocompleteEnabled, toggleAutocomplete] = useAutocompleteFlags(schema);
const [autoLimitAvailable, autoLimitChecked, setAutoLimit] = useAutoLimitFlags(dataSource, query, setQuery);
const [handleQueryEditorChange] = useDebouncedCallback(queryText => {
setQuery(extend(query.clone(), { query: queryText }));
}, 100);
useEffect(() => {
// TODO: ignore new pages?
recordEvent("view_source", "query", query.id);
}, [query.id]);
useEffect(() => {
document.title = query.name;
}, [query.name]);
const updateQuery = useUpdateQuery(query, setQuery);
const updateQueryDescription = useUpdateQueryDescription(query, setQuery);
const querySyntax = dataSource ? dataSource.syntax || "sql" : null;
const isFormatQueryAvailable = queryFormat.isFormatQueryAvailable(querySyntax);
const formatQuery = () => {
try {
const formattedQueryText = queryFormat.formatQuery(query.query, querySyntax);
setQuery(extend(query.clone(), { query: formattedQueryText }));
} catch (err) {
notification.error(String(err));
}
};
const handleDataSourceChange = useCallback(
dataSourceId => {
if (dataSourceId) {
try {
localStorage.setItem("lastSelectedDataSourceId", dataSourceId);
} catch (e) {
// `localStorage.setItem` may throw exception if there are no enough space - in this case it could be ignored
}
}
if (query.data_source_id !== dataSourceId) {
recordEvent("update_data_source", "query", query.id, { dataSourceId });
const updates = {
data_source_id: dataSourceId,
latest_query_data_id: null,
latest_query_data: null,
};
setQuery(extend(query.clone(), updates));
updateQuery(updates, { successMessage: null }); // show message only on error
}
},
[query, setQuery, updateQuery]
);
useEffect(() => {
// choose data source id for new queries
if (dataSourcesLoaded && queryFlags.isNew) {
const firstDataSourceId = dataSources.length > 0 ? dataSources[0].id : null;
handleDataSourceChange(
chooseDataSourceId(
[query.data_source_id, localStorage.getItem("lastSelectedDataSourceId"), firstDataSourceId],
dataSources
)
);
}
}, [query.data_source_id, queryFlags.isNew, dataSourcesLoaded, dataSources, handleDataSourceChange]);
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 handleSchemaItemSelect = useCallback(schemaItem => {
if (editorRef.current) {
editorRef.current.paste(schemaItem);
}
}, []);
const [selectedText, setSelectedText] = useState(null);
const doExecuteQuery = useCallback(
(skipParametersDirtyFlag = false) => {
if (!queryFlags.canExecute || (!skipParametersDirtyFlag && (areParametersDirty || isQueryExecuting))) {
return;
}
if (isDirty || !isEmpty(selectedText)) {
executeQuery(null, () => {
return query.getQueryResultByText(0, selectedText);
});
} else {
executeQuery();
}
},
[query, queryFlags.canExecute, areParametersDirty, isQueryExecuting, isDirty, selectedText, executeQuery]
);
const [isQuerySaving, setIsQuerySaving] = useState(false);
const doSaveQuery = useCallback(() => {
if (!isQuerySaving) {
setIsQuerySaving(true);
saveQuery().finally(() => setIsQuerySaving(false));
}
}, [isQuerySaving, saveQuery]);
const addVisualization = useAddVisualizationDialog(query, queryResult, doSaveQuery, (newQuery, visualization) => {
setQuery(newQuery);
setSelectedVisualization(visualization.id);
});
const editVisualization = useEditVisualizationDialog(query, queryResult, newQuery => setQuery(newQuery));
const deleteVisualization = useDeleteVisualization(query, setQuery);
return (
<div className={cx("query-page-wrapper", { "query-fixed-layout": !isMobile })}>
<QuerySourceAlerts query={query} dataSourcesAvailable={!dataSourcesLoaded || dataSources.length > 0} />
<div className="container w-100 p-b-10">
<QueryPageHeader
query={query}
dataSource={dataSource}
sourceMode
selectedVisualization={selectedVisualization}
headerExtra={<DynamicComponent name="QuerySource.HeaderExtra" query={query} />}
onChange={setQuery}
/>
</div>
<main className="query-fullscreen">
<Resizable direction="horizontal" sizeAttribute="flex-basis" toggleShortcut="Alt+Shift+D, Alt+D">
<nav>
{dataSourcesLoaded && (
<div className="editor__left__data-source">
<DynamicComponent
name={"QuerySourceDropdown"}
dataSources={dataSources}
value={dataSource ? dataSource.id : undefined}
disabled={!queryFlags.canEdit || !dataSourcesLoaded || dataSources.length === 0}
loading={!dataSourcesLoaded}
onChange={handleDataSourceChange}
/>
</div>
)}
<div className="editor__left__schema">
<SchemaBrowser
dataSource={dataSource}
options={query.options.schemaOptions}
onOptionsUpdate={schemaOptions =>
setQuery(extend(query.clone(), { options: { ...query.options, schemaOptions } }))
}
onSchemaUpdate={setSchema}
onItemSelect={handleSchemaItemSelect}
/>
</div>
{!query.isNew() && (
<div className="query-page-query-description">
<EditInPlace
isEditable={queryFlags.canEdit}
markdown
ignoreBlanks={false}
placeholder="Add description"
value={query.description}
onDone={updateQueryDescription}
multiline
/>
</div>
)}
{!query.isNew() && <QueryMetadata layout="table" query={query} onEditSchedule={editSchedule} />}
</nav>
</Resizable>
<div className="content">
<div className="flex-fill p-relative">
<div
className="p-absolute d-flex flex-column p-l-15 p-r-15"
style={{ left: 0, top: 0, right: 0, bottom: 0, overflow: "auto" }}>
<Resizable direction="vertical" sizeAttribute="flex-basis">
<div className="row editor">
<section className="query-editor-wrapper" data-test="QueryEditor">
<QueryEditor
ref={editorRef}
data-executing={isQueryExecuting ? "true" : null}
syntax={dataSource ? dataSource.syntax : null}
value={query.query}
schema={schema}
autocompleteEnabled={autocompleteAvailable && autocompleteEnabled}
onChange={handleQueryEditorChange}
onSelectionChange={setSelectedText}
/>
<QueryEditor.Controls
addParameterButtonProps={{
title: "Add New Parameter",
shortcut: "mod+p",
onClick: openAddNewParameterDialog,
}}
formatButtonProps={{
title: isFormatQueryAvailable
? "Format Query"
: "Query formatting is not supported for your Data Source syntax",
disabled: !dataSource || !isFormatQueryAvailable,
shortcut: isFormatQueryAvailable ? "mod+shift+f" : null,
onClick: formatQuery,
}}
saveButtonProps={
queryFlags.canEdit && {
text: (
<React.Fragment>
<span className="hidden-xs">Save</span>
{isDirty && !isQuerySaving ? "*" : null}
</React.Fragment>
),
shortcut: "mod+s",
onClick: doSaveQuery,
loading: isQuerySaving,
}
}
executeButtonProps={{
disabled: !queryFlags.canExecute || isQueryExecuting || areParametersDirty,
shortcut: "mod+enter, alt+enter, ctrl+enter, shift+enter",
onClick: doExecuteQuery,
text: (
<span className="hidden-xs">{selectedText === null ? "Execute" : "Execute Selected"}</span>
),
}}
autocompleteToggleProps={{
available: autocompleteAvailable,
enabled: autocompleteEnabled,
onToggle: toggleAutocomplete,
}}
autoLimitCheckboxProps={{
available: autoLimitAvailable,
checked: autoLimitChecked,
onChange: setAutoLimit,
}}
dataSourceSelectorProps={
dataSource
? {
disabled: !queryFlags.canEdit,
value: dataSource.id,
onChange: handleDataSourceChange,
options: map(dataSources, ds => ({ value: ds.id, label: ds.name })),
}
: false
}
/>
</section>
</div>
</Resizable>
{!queryFlags.isNew && <QueryMetadata layout="horizontal" query={query} onEditSchedule={editSchedule} />}
<section className="query-results-wrapper">
{query.hasParameters() && (
<div className="query-parameters-wrapper">
<Parameters
editable={queryFlags.canEdit}
sortable={queryFlags.canEdit}
disableUrlUpdate={queryFlags.isNew}
parameters={parameters}
onPendingValuesChange={() => updateParametersDirtyFlag()}
onValuesChange={() => {
updateParametersDirtyFlag(false);
doExecuteQuery(true);
}}
onParametersEdit={() => {
// save if query clean
// https://discuss.redash.io/t/query-unsaved-changes-indication/3302/5
if (!isDirty) {
saveQuery();
}
}}
/>
</div>
)}
{(executionError || isQueryExecuting) && (
<div className="query-alerts">
<QueryExecutionStatus
status={executionStatus}
updatedAt={updatedAt}
error={executionError}
isCancelling={isExecutionCancelling}
onCancel={cancelExecution}
/>
</div>
)}
<React.Fragment>
{queryResultData.log.length > 0 && (
<div className="query-results-log">
<p>Log Information:</p>
{map(queryResultData.log, (line, index) => (
<p key={`log-line-${index}`} className="query-log-line">
{line}
</p>
))}
</div>
)}
{loadedInitialResults && !(queryFlags.isNew && !queryResult) && (
<QueryVisualizationTabs
queryResult={queryResult}
visualizations={query.visualizations}
showNewVisualizationButton={queryFlags.canEdit && queryResultData.status === ExecutionStatus.DONE}
canDeleteVisualizations={queryFlags.canEdit}
selectedTab={selectedVisualization}
onChangeTab={setSelectedVisualization}
onAddVisualization={addVisualization}
onDeleteVisualization={deleteVisualization}
refreshButton={
<Button
type="primary"
disabled={!queryFlags.canExecute || areParametersDirty}
loading={isQueryExecuting}
onClick={doExecuteQuery}>
{!isQueryExecuting && <i className="zmdi zmdi-refresh m-r-5" aria-hidden="true" />}
Refresh Now
</Button>
}
/>
)}
</React.Fragment>
</section>
</div>
</div>
{queryResult && !queryResult.getError() && (
<div className="bottom-controller-container">
<QueryExecutionMetadata
query={query}
queryResult={queryResult}
selectedVisualization={selectedVisualization}
isQueryExecuting={isQueryExecuting}
showEditVisualizationButton={!queryFlags.isNew && queryFlags.canEdit}
onEditVisualization={editVisualization}
/>
</div>
)}
</div>
</main>
</div>
);
}
QuerySource.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
};
const QuerySourcePage = wrapQueryPage(QuerySource);
routes.register(
"Queries.New",
routeWithUserSession({
path: "/queries/new",
render: pageProps => <QuerySourcePage {...pageProps} />,
bodyClass: "fixed-layout",
})
);
routes.register(
"Queries.Edit",
routeWithUserSession({
path: "/queries/:queryId/source",
render: pageProps => <QuerySourcePage {...pageProps} />,
bodyClass: "fixed-layout",
})
);