client/app/pages/queries/VisualizationEmbed.jsx (258 lines of code) (raw):
import { find, has } from "lodash";
import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import moment from "moment";
import { markdown } from "markdown";
import Button from "antd/lib/button";
import Dropdown from "antd/lib/dropdown";
import Menu from "antd/lib/menu";
import Tooltip from "@/components/Tooltip";
import Link from "@/components/Link";
import routeWithApiKeySession from "@/components/ApplicationArea/routeWithApiKeySession";
import Parameters from "@/components/Parameters";
import { Moment } from "@/components/proptypes";
import TimeAgo from "@/components/TimeAgo";
import Timer from "@/components/Timer";
import QueryResultsLink from "@/components/EditVisualizationButton/QueryResultsLink";
import VisualizationName from "@/components/visualizations/VisualizationName";
import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer";
import FileOutlinedIcon from "@ant-design/icons/FileOutlined";
import FileExcelOutlinedIcon from "@ant-design/icons/FileExcelOutlined";
import { VisualizationType } from "@redash/viz/lib";
import HtmlContent from "@redash/viz/lib/components/HtmlContent";
import { formatDateTime } from "@/lib/utils";
import useImmutableCallback from "@/lib/hooks/useImmutableCallback";
import { Query } from "@/services/query";
import location from "@/services/location";
import routes from "@/services/routes";
import logoUrl from "@/assets/images/redash_icon_small.png";
function VisualizationEmbedHeader({ queryName, queryDescription, visualization }) {
return (
<div className="embed-heading p-b-10 p-r-15 p-l-15">
<h3>
<img src={logoUrl} alt="Redash Logo" style={{ height: "24px", verticalAlign: "text-bottom" }} />
<VisualizationName visualization={visualization} /> {queryName}
{queryDescription && (
<small>
<HtmlContent className="markdown text-muted">{markdown.toHTML(queryDescription || "")}</HtmlContent>
</small>
)}
</h3>
</div>
);
}
VisualizationEmbedHeader.propTypes = {
queryName: PropTypes.string.isRequired,
queryDescription: PropTypes.string,
visualization: VisualizationType.isRequired,
};
VisualizationEmbedHeader.defaultProps = { queryDescription: "" };
function VisualizationEmbedFooter({
query,
queryResults,
updatedAt,
refreshStartedAt,
queryUrl,
hideTimestamp,
apiKey,
}) {
const downloadMenu = (
<Menu>
<Menu.Item>
<QueryResultsLink
fileType="csv"
query={query}
queryResult={queryResults}
apiKey={apiKey}
disabled={!queryResults || !queryResults.getData || !queryResults.getData()}
embed>
<FileOutlinedIcon /> Download as CSV File
</QueryResultsLink>
</Menu.Item>
<Menu.Item>
<QueryResultsLink
fileType="tsv"
query={query}
queryResult={queryResults}
apiKey={apiKey}
disabled={!queryResults || !queryResults.getData || !queryResults.getData()}
embed>
<FileOutlinedIcon /> Download as TSV File
</QueryResultsLink>
</Menu.Item>
<Menu.Item>
<QueryResultsLink
fileType="xlsx"
query={query}
queryResult={queryResults}
apiKey={apiKey}
disabled={!queryResults || !queryResults.getData || !queryResults.getData()}
embed>
<FileExcelOutlinedIcon /> Download as Excel File
</QueryResultsLink>
</Menu.Item>
</Menu>
);
return (
<div className="tile__bottom-control">
{!hideTimestamp && (
<span>
<span className="small hidden-print">
<i className="zmdi zmdi-time-restore" aria-hidden="true" />{" "}
{refreshStartedAt ? <Timer from={refreshStartedAt} /> : <TimeAgo date={updatedAt} />}
</span>
<span className="small visible-print">
<i className="zmdi zmdi-time-restore" aria-hidden="true" /> {formatDateTime(updatedAt)}
</span>
</span>
)}
{queryUrl && (
<span className="hidden-print">
<Tooltip title="Open in Redash">
<Link.Button className="icon-button" href={queryUrl} target="_blank">
<i className="fa fa-external-link" aria-hidden="true" />
<span className="sr-only">Open in Redash</span>
</Link.Button>
</Tooltip>
{!query.hasParameters() && (
<Dropdown overlay={downloadMenu} disabled={!queryResults} trigger={["click"]} placement="topLeft">
<Button loading={!queryResults && !!refreshStartedAt} className="m-l-5">
Download Dataset
<i className="fa fa-caret-up m-l-5" aria-hidden="true" />
</Button>
</Dropdown>
)}
</span>
)}
</div>
);
}
VisualizationEmbedFooter.propTypes = {
query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
queryResults: PropTypes.object, // eslint-disable-line react/forbid-prop-types
updatedAt: PropTypes.string,
refreshStartedAt: Moment,
queryUrl: PropTypes.string,
hideTimestamp: PropTypes.bool,
apiKey: PropTypes.string,
};
VisualizationEmbedFooter.defaultProps = {
queryResults: null,
updatedAt: null,
refreshStartedAt: null,
queryUrl: null,
hideTimestamp: false,
apiKey: null,
};
function VisualizationEmbed({ queryId, visualizationId, apiKey, onError }) {
const [query, setQuery] = useState(null);
const [error, setError] = useState(null);
const [refreshStartedAt, setRefreshStartedAt] = useState(null);
const [queryResults, setQueryResults] = useState(null);
const handleError = useImmutableCallback(onError);
useEffect(() => {
let isCancelled = false;
Query.get({ id: queryId })
.then(result => {
if (!isCancelled) {
setQuery(result);
}
})
.catch(handleError);
return () => {
isCancelled = true;
};
}, [queryId, handleError]);
const refreshQueryResults = useCallback(() => {
if (query) {
setError(null);
setRefreshStartedAt(moment());
query
.getQueryResultPromise()
.then(result => {
setQueryResults(result);
})
.catch(err => {
setError(err.getError());
})
.finally(() => setRefreshStartedAt(null));
}
}, [query]);
useEffect(() => {
document.querySelector("body").classList.add("headless");
refreshQueryResults();
}, [refreshQueryResults]);
if (!query) {
return null;
}
const hideHeader = has(location.search, "hide_header");
const hideParametersUI = has(location.search, "hide_parameters");
const hideQueryLink = has(location.search, "hide_link");
const hideTimestamp = has(location.search, "hide_timestamp");
const showQueryDescription = has(location.search, "showDescription");
visualizationId = parseInt(visualizationId, 10);
const visualization = find(query.visualizations, vis => vis.id === visualizationId);
if (!visualization) {
// call error handler async, otherwise it will destroy the component on render phase
setTimeout(() => {
onError(new Error("Visualization does not exist"));
}, 10);
return null;
}
return (
<div className="tile m-t-10 m-l-10 m-r-10 p-t-10 embed__vis" data-test="VisualizationEmbed">
{!hideHeader && (
<VisualizationEmbedHeader
queryName={query.name}
queryDescription={showQueryDescription ? query.description : null}
visualization={visualization}
/>
)}
<div className="col-md-12 query__vis">
{!hideParametersUI && query.hasParameters() && (
<div className="p-t-15 p-b-10">
<Parameters parameters={query.getParametersDefs()} onValuesChange={refreshQueryResults} />
</div>
)}
{error && <div className="alert alert-danger" data-test="ErrorMessage">{`Error: ${error}`}</div>}
{!error && queryResults && (
<VisualizationRenderer visualization={visualization} queryResult={queryResults} context="widget" />
)}
{!queryResults && refreshStartedAt && (
<div className="d-flex justify-content-center">
<div className="spinner">
<i className="zmdi zmdi-refresh zmdi-hc-spin zmdi-hc-5x" aria-hidden="true" />
<span className="sr-only">Refreshing...</span>
</div>
</div>
)}
</div>
<VisualizationEmbedFooter
query={query}
queryResults={queryResults}
updatedAt={queryResults ? queryResults.getUpdatedAt() : undefined}
refreshStartedAt={refreshStartedAt}
queryUrl={!hideQueryLink ? query.getUrl() : null}
hideTimestamp={hideTimestamp}
apiKey={apiKey}
/>
</div>
);
}
VisualizationEmbed.propTypes = {
queryId: PropTypes.string.isRequired,
visualizationId: PropTypes.string,
apiKey: PropTypes.string.isRequired,
onError: PropTypes.func,
};
VisualizationEmbed.defaultProps = {
onError: () => {},
};
routes.register(
"Visualizations.ViewShared",
routeWithApiKeySession({
path: "/embed/query/:queryId/visualization/:visualizationId",
render: pageProps => <VisualizationEmbed {...pageProps} />,
getApiKey: () => location.search.api_key,
})
);