client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx (339 lines of code) (raw):

import React, { useState } from "react"; import PropTypes from "prop-types"; import { compact, isEmpty, invoke, map } from "lodash"; import { markdown } from "markdown"; import cx from "classnames"; import Menu from "antd/lib/menu"; import HtmlContent from "@redash/viz/lib/components/HtmlContent"; import { currentUser } from "@/services/auth"; import recordEvent from "@/services/recordEvent"; import { formatDateTime } from "@/lib/utils"; import Link from "@/components/Link"; import Parameters from "@/components/Parameters"; import TimeAgo from "@/components/TimeAgo"; import Timer from "@/components/Timer"; import { Moment } from "@/components/proptypes"; import QueryLink from "@/components/QueryLink"; import { FiltersType } from "@/components/Filters"; import PlainButton from "@/components/PlainButton"; import ExpandedWidgetDialog from "@/components/dashboards/ExpandedWidgetDialog"; import EditParameterMappingsDialog from "@/components/dashboards/EditParameterMappingsDialog"; import VisualizationRenderer from "@/components/visualizations/VisualizationRenderer"; import Widget from "./Widget"; function visualizationWidgetMenuOptions({ widget, canEditDashboard, onParametersEdit }) { const canViewQuery = currentUser.hasPermission("view_query"); const canEditParameters = canEditDashboard && !isEmpty(invoke(widget, "query.getParametersDefs")); const widgetQueryResult = widget.getQueryResult(); const isQueryResultEmpty = !widgetQueryResult || !widgetQueryResult.isEmpty || widgetQueryResult.isEmpty(); const downloadLink = fileType => widgetQueryResult.getLink(widget.getQuery().id, fileType); const downloadName = fileType => widgetQueryResult.getName(widget.getQuery().name, fileType); return compact([ <Menu.Item key="download_csv" disabled={isQueryResultEmpty}> {!isQueryResultEmpty ? ( <Link href={downloadLink("csv")} download={downloadName("csv")} target="_self"> Download as CSV File </Link> ) : ( "Download as CSV File" )} </Menu.Item>, <Menu.Item key="download_tsv" disabled={isQueryResultEmpty}> {!isQueryResultEmpty ? ( <Link href={downloadLink("tsv")} download={downloadName("tsv")} target="_self"> Download as TSV File </Link> ) : ( "Download as TSV File" )} </Menu.Item>, <Menu.Item key="download_excel" disabled={isQueryResultEmpty}> {!isQueryResultEmpty ? ( <Link href={downloadLink("xlsx")} download={downloadName("xlsx")} target="_self"> Download as Excel File </Link> ) : ( "Download as Excel File" )} </Menu.Item>, (canViewQuery || canEditParameters) && <Menu.Divider key="divider" />, canViewQuery && ( <Menu.Item key="view_query"> <Link href={widget.getQuery().getUrl(true, widget.visualization.id)}>View Query</Link> </Menu.Item> ), canEditParameters && ( <Menu.Item key="edit_parameters" onClick={onParametersEdit}> Edit Parameters </Menu.Item> ), ]); } function RefreshIndicator({ refreshStartedAt }) { return ( <div className="refresh-indicator"> <div className="refresh-icon"> <i className="zmdi zmdi-refresh zmdi-hc-spin" aria-hidden="true" /> <span className="sr-only">Refreshing...</span> </div> <Timer from={refreshStartedAt} /> </div> ); } RefreshIndicator.propTypes = { refreshStartedAt: Moment }; RefreshIndicator.defaultProps = { refreshStartedAt: null }; function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, isEditing, onParametersUpdate, onParametersEdit, }) { const canViewQuery = currentUser.hasPermission("view_query"); return ( <> <RefreshIndicator refreshStartedAt={refreshStartedAt} /> <div className="t-header widget clearfix"> <div className="th-title"> <p> <QueryLink query={widget.getQuery()} visualization={widget.visualization} readOnly={!canViewQuery} /> </p> {!isEmpty(widget.getQuery().description) && ( <HtmlContent className="text-muted markdown query--description"> {markdown.toHTML(widget.getQuery().description || "")} </HtmlContent> )} </div> </div> {!isEmpty(parameters) && ( <div className="m-b-10"> <Parameters parameters={parameters} sortable={isEditing} appendSortableToParent={false} onValuesChange={onParametersUpdate} onParametersEdit={onParametersEdit} /> </div> )} </> ); } VisualizationWidgetHeader.propTypes = { widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types refreshStartedAt: Moment, parameters: PropTypes.arrayOf(PropTypes.object), isEditing: PropTypes.bool, onParametersUpdate: PropTypes.func, onParametersEdit: PropTypes.func, }; VisualizationWidgetHeader.defaultProps = { refreshStartedAt: null, onParametersUpdate: () => {}, onParametersEdit: () => {}, isEditing: false, parameters: [], }; function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) { const widgetQueryResult = widget.getQueryResult(); const updatedAt = invoke(widgetQueryResult, "getUpdatedAt"); const [refreshClickButtonId, setRefreshClickButtonId] = useState(); const refreshWidget = buttonId => { if (!refreshClickButtonId) { setRefreshClickButtonId(buttonId); onRefresh().finally(() => setRefreshClickButtonId(null)); } }; return widgetQueryResult ? ( <> <span> {!isPublic && !!widgetQueryResult && ( <PlainButton className="refresh-button hidden-print btn btn-sm btn-default btn-transparent" onClick={() => refreshWidget(1)} data-test="RefreshButton"> <i className={cx("zmdi zmdi-refresh", { "zmdi-hc-spin": refreshClickButtonId === 1 })} aria-hidden="true" /> <span className="sr-only"> {refreshClickButtonId === 1 ? "Refreshing, please wait. " : "Press to refresh. "} </span>{" "} <TimeAgo date={updatedAt} /> </PlainButton> )} <span className="visible-print"> <i className="zmdi zmdi-time-restore" aria-hidden="true" /> {formatDateTime(updatedAt)} </span> {isPublic && ( <span className="small hidden-print"> <i className="zmdi zmdi-time-restore" aria-hidden="true" /> <TimeAgo date={updatedAt} /> </span> )} </span> <span> {!isPublic && ( <PlainButton className="btn btn-sm btn-default hidden-print btn-transparent btn__refresh" onClick={() => refreshWidget(2)}> <i className={cx("zmdi zmdi-refresh", { "zmdi-hc-spin": refreshClickButtonId === 2 })} aria-hidden="true" /> <span className="sr-only"> {refreshClickButtonId === 2 ? "Refreshing, please wait." : "Press to refresh."} </span> </PlainButton> )} <PlainButton className="btn btn-sm btn-default hidden-print btn-transparent btn__refresh" onClick={onExpand}> <i className="zmdi zmdi-fullscreen" aria-hidden="true" /> </PlainButton> </span> </> ) : null; } VisualizationWidgetFooter.propTypes = { widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types isPublic: PropTypes.bool, onRefresh: PropTypes.func.isRequired, onExpand: PropTypes.func.isRequired, }; VisualizationWidgetFooter.defaultProps = { isPublic: false }; class VisualizationWidget extends React.Component { static propTypes = { widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types filters: FiltersType, isPublic: PropTypes.bool, isLoading: PropTypes.bool, canEdit: PropTypes.bool, isEditing: PropTypes.bool, onLoad: PropTypes.func, onRefresh: PropTypes.func, onDelete: PropTypes.func, onParameterMappingsChange: PropTypes.func, }; static defaultProps = { filters: [], isPublic: false, isLoading: false, canEdit: false, isEditing: false, onLoad: () => {}, onRefresh: () => {}, onDelete: () => {}, onParameterMappingsChange: () => {}, }; constructor(props) { super(props); this.state = { localParameters: props.widget.getLocalParameters(), localFilters: props.filters, }; } componentDidMount() { const { widget, onLoad } = this.props; recordEvent("view", "query", widget.visualization.query.id, { dashboard: true }); recordEvent("view", "visualization", widget.visualization.id, { dashboard: true }); onLoad(); } onLocalFiltersChange = localFilters => { this.setState({ localFilters }); }; expandWidget = () => { ExpandedWidgetDialog.showModal({ widget: this.props.widget, filters: this.state.localFilters }); }; editParameterMappings = () => { const { widget, dashboard, onRefresh, onParameterMappingsChange } = this.props; EditParameterMappingsDialog.showModal({ dashboard, widget, }).onClose(valuesChanged => { // refresh widget if any parameter value has been updated if (valuesChanged) { onRefresh(); } onParameterMappingsChange(); this.setState({ localParameters: widget.getLocalParameters() }); }); }; renderVisualization() { const { widget, filters } = this.props; const widgetQueryResult = widget.getQueryResult(); const widgetStatus = widgetQueryResult && widgetQueryResult.getStatus(); switch (widgetStatus) { case "failed": return ( <div className="body-row-auto scrollbox"> {widgetQueryResult.getError() && ( <div className="alert alert-danger m-5"> Error running query: <strong>{widgetQueryResult.getError()}</strong> </div> )} </div> ); case "done": return ( <div className="body-row-auto scrollbox"> <VisualizationRenderer visualization={widget.visualization} queryResult={widgetQueryResult} filters={filters} onFiltersChange={this.onLocalFiltersChange} context="widget" /> </div> ); default: return ( <div className="body-row-auto spinner-container" role="status" aria-live="polite" aria-relevant="additions removals"> <div className="spinner"> <i className="zmdi zmdi-refresh zmdi-hc-spin zmdi-hc-5x" aria-hidden="true" /> <span className="sr-only">Loading...</span> </div> </div> ); } } render() { const { widget, isLoading, isPublic, canEdit, isEditing, onRefresh } = this.props; const { localParameters } = this.state; const widgetQueryResult = widget.getQueryResult(); const isRefreshing = isLoading && !!(widgetQueryResult && widgetQueryResult.getStatus()); const onParametersEdit = parameters => { const paramOrder = map(parameters, "name"); widget.options.paramOrder = paramOrder; widget.save("options", { paramOrder }); }; return ( <Widget {...this.props} className="widget-visualization" menuOptions={visualizationWidgetMenuOptions({ widget, canEditDashboard: canEdit, onParametersEdit: this.editParameterMappings, })} header={ <VisualizationWidgetHeader widget={widget} refreshStartedAt={isRefreshing ? widget.refreshStartedAt : null} parameters={localParameters} isEditing={isEditing} onParametersUpdate={onRefresh} onParametersEdit={onParametersEdit} /> } footer={ <VisualizationWidgetFooter widget={widget} isPublic={isPublic} onRefresh={onRefresh} onExpand={this.expandWidget} /> } tileProps={{ "data-refreshing": isRefreshing }}> {this.renderVisualization()} </Widget> ); } } export default VisualizationWidget;