diff --git a/client/app/assets/less/inc/visualizations/misc.less b/client/app/assets/less/inc/visualizations/misc.less index cc5600bd16..2b9376acce 100644 --- a/client/app/assets/less/inc/visualizations/misc.less +++ b/client/app/assets/less/inc/visualizations/misc.less @@ -1,4 +1,4 @@ -visualization-renderer { +.visualization-renderer { display: block; .pagination, diff --git a/client/app/assets/less/inc/visualizations/pivot-table.less b/client/app/assets/less/inc/visualizations/pivot-table.less index 6e41ffa0a1..6fa85b4159 100644 --- a/client/app/assets/less/inc/visualizations/pivot-table.less +++ b/client/app/assets/less/inc/visualizations/pivot-table.less @@ -1,4 +1,4 @@ .pivot-table-renderer > table, -visualization-renderer > .visualization-renderer-wrapper { +.visualization-renderer > .visualization-renderer-wrapper { overflow: auto; } diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less index 621630ebd3..e14cec8ab5 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -344,7 +344,7 @@ a.label-tag { } .pivot-table-renderer > table, - visualization-renderer > .visualization-renderer-wrapper { + .visualization-renderer > .visualization-renderer-wrapper { overflow: visible; } diff --git a/client/app/components/Parameters.jsx b/client/app/components/Parameters.jsx index 285b6a6237..89ea3c9690 100644 --- a/client/app/components/Parameters.jsx +++ b/client/app/components/Parameters.jsx @@ -113,10 +113,10 @@ export class Parameters extends React.Component { this.setState(({ parameters }) => { const parametersWithPendingValues = parameters.filter(p => p.hasPendingValue); forEach(parameters, p => p.applyPendingValue()); - onValuesChange(parametersWithPendingValues); if (!disableUrlUpdate) { updateUrl(parameters); } + onValuesChange(parametersWithPendingValues); return { parameters }; }); }; diff --git a/client/app/components/QueryLink.jsx b/client/app/components/QueryLink.jsx new file mode 100644 index 0000000000..e01b528dfe --- /dev/null +++ b/client/app/components/QueryLink.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { VisualizationType } from '@/visualizations'; +import { VisualizationName } from '@/visualizations/VisualizationName'; + +function QueryLink({ query, visualization, readOnly }) { + const getUrl = () => { + let hash = null; + if (visualization) { + if (visualization.type === 'TABLE') { + // link to hard-coded table tab instead of the (hidden) visualization tab + hash = 'table'; + } else { + hash = visualization.id; + } + } + + return query.getUrl(false, hash); + }; + + return ( + + {' '} + {query.name} + + ); +} + +QueryLink.propTypes = { + query: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + visualization: VisualizationType, + readOnly: PropTypes.bool, +}; + +QueryLink.defaultProps = { + visualization: null, + readOnly: false, +}; + +export default QueryLink; diff --git a/client/app/components/Timer.jsx b/client/app/components/Timer.jsx index 72c5b7af12..ebaa976b74 100644 --- a/client/app/components/Timer.jsx +++ b/client/app/components/Timer.jsx @@ -1,5 +1,5 @@ +import React, { useMemo, useEffect } from 'react'; import moment from 'moment'; -import { useMemo, useEffect } from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; import { Moment } from '@/components/proptypes'; @@ -17,7 +17,7 @@ export function Timer({ from }) { const diff = moment.now() - startTime; const format = diff > 1000 * 60 * 60 ? 'HH:mm:ss' : 'mm:ss'; // no HH under an hour - return moment.utc(diff).format(format); + return ({moment.utc(diff).format(format)}); } Timer.propTypes = { diff --git a/client/app/components/dashboards/AutoHeightController.js b/client/app/components/dashboards/AutoHeightController.js index d0aa8b5ddd..4763dcb999 100644 --- a/client/app/components/dashboards/AutoHeightController.js +++ b/client/app/components/dashboards/AutoHeightController.js @@ -5,7 +5,7 @@ import { includes, reduce, some } from 'lodash'; const WIDGET_SELECTOR = '[data-widgetid="{0}"]'; const WIDGET_CONTENT_SELECTOR = [ '.widget-header', // header - 'visualization-renderer', // visualization + '.visualization-renderer', // visualization '.scrollbox .alert', // error state '.spinner-container', // loading state '.tile__bottom-control', // footer diff --git a/client/app/components/dashboards/DashboardGrid.jsx b/client/app/components/dashboards/DashboardGrid.jsx index c6fc7f175e..c16c85e65d 100644 --- a/client/app/components/dashboards/DashboardGrid.jsx +++ b/client/app/components/dashboards/DashboardGrid.jsx @@ -4,10 +4,11 @@ import { chain, cloneDeep, find } from 'lodash'; import { react2angular } from 'react2angular'; import cx from 'classnames'; import { Responsive, WidthProvider } from 'react-grid-layout'; -import { DashboardWidget } from '@/components/dashboards/widget'; +import { VisualizationWidget, TextboxWidget, RestrictedWidget } from '@/components/dashboards/dashboard-widget'; import { FiltersType } from '@/components/Filters'; import cfg from '@/config/dashboard-grid-options'; import AutoHeightController from './AutoHeightController'; +import { WidgetTypeEnum } from '@/services/widget'; import 'react-grid-layout/css/styles.css'; import './dashboard-grid.less'; @@ -41,16 +42,22 @@ class DashboardGrid extends React.Component { widgets: PropTypes.arrayOf(WidgetType).isRequired, filters: FiltersType, onBreakpointChange: PropTypes.func, + onLoadWidget: PropTypes.func, + onRefreshWidget: PropTypes.func, onRemoveWidget: PropTypes.func, onLayoutChange: PropTypes.func, + onParameterMappingsChange: PropTypes.func, }; static defaultProps = { isPublic: false, filters: [], + onLoadWidget: () => {}, + onRefreshWidget: () => {}, onRemoveWidget: () => {}, onLayoutChange: () => {}, onBreakpointChange: () => {}, + onParameterMappingsChange: () => {}, }; static normalizeFrom(widget) { @@ -168,7 +175,8 @@ class DashboardGrid extends React.Component { render() { const className = cx('dashboard-wrapper', this.props.isEditing ? 'editing-mode' : 'preview-mode'); - const { onRemoveWidget, dashboard, widgets } = this.props; + const { onLoadWidget, onRefreshWidget, onRemoveWidget, + onParameterMappingsChange, filters, dashboard, isPublic, widgets } = this.props; return (
@@ -186,23 +194,37 @@ class DashboardGrid extends React.Component { onBreakpointChange={this.onBreakpointChange} breakpoints={{ [MULTI]: cfg.mobileBreakPoint, [SINGLE]: 0 }} > - {widgets.map(widget => ( -
- onRemoveWidget(widget.id)} - public={this.props.isPublic} - /> -
- ))} + {widgets.map((widget) => { + const widgetProps = { + widget, + filters, + isPublic, + canEdit: dashboard.canEdit(), + onDelete: () => onRemoveWidget(widget.id), + }; + const { type } = widget; + return ( +
+ {type === WidgetTypeEnum.VISUALIZATION && ( + onLoadWidget(widget)} + onRefresh={() => onRefreshWidget(widget)} + onParameterMappingsChange={onParameterMappingsChange} + /> + )} + {type === WidgetTypeEnum.TEXTBOX && } + {type === WidgetTypeEnum.RESTRICTED && } +
+ ); + })}
); diff --git a/client/app/components/dashboards/ExpandedWidgetDialog.jsx b/client/app/components/dashboards/ExpandedWidgetDialog.jsx new file mode 100644 index 0000000000..ee6337936d --- /dev/null +++ b/client/app/components/dashboards/ExpandedWidgetDialog.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from 'antd/lib/button'; +import Modal from 'antd/lib/modal'; +import { VisualizationRenderer } from '@/visualizations/VisualizationRenderer'; +import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; +import { VisualizationName } from '@/visualizations/VisualizationName'; + +function ExpandedWidgetDialog({ dialog, widget }) { + return ( + + {' '} + {widget.getQuery().name} + + )} + width="95%" + footer={()} + > + + + ); +} + +ExpandedWidgetDialog.propTypes = { + dialog: DialogPropType.isRequired, + widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +export default wrapDialog(ExpandedWidgetDialog); diff --git a/client/app/components/dashboards/TextboxDialog.jsx b/client/app/components/dashboards/TextboxDialog.jsx index b85509a822..a6106a5cda 100644 --- a/client/app/components/dashboards/TextboxDialog.jsx +++ b/client/app/components/dashboards/TextboxDialog.jsx @@ -14,7 +14,6 @@ import './TextboxDialog.less'; class TextboxDialog extends React.Component { static propTypes = { - dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types dialog: DialogPropType.isRequired, onConfirm: PropTypes.func.isRequired, text: PropTypes.string, diff --git a/client/app/components/dashboards/dashboard-grid.less b/client/app/components/dashboards/dashboard-grid.less index 83af1a7843..ac58381f4c 100644 --- a/client/app/components/dashboards/dashboard-grid.less +++ b/client/app/components/dashboards/dashboard-grid.less @@ -5,3 +5,32 @@ } } } + +// react-grid-layout overrides +.react-grid-item { + + // placeholder color + &.react-grid-placeholder { + border-radius: 3px; + background-color: #E0E6EB; + opacity: 0.5; + } + + // resize placeholder behind widget, the lib's default is above 🤷‍♂️ + &.resizing { + z-index: 3; + } + + // auto-height animation + &.cssTransforms:not(.resizing) { + transition-property: transform, height; // added ", height" + } + + // resize handle size + & > .react-resizable-handle::after { + width: 11px; + height: 11px; + right: 5px; + bottom: 5px; + } +} diff --git a/client/app/components/dashboards/dashboard-widget/RestrictedWidget.jsx b/client/app/components/dashboards/dashboard-widget/RestrictedWidget.jsx new file mode 100644 index 0000000000..01e0b5c7d6 --- /dev/null +++ b/client/app/components/dashboards/dashboard-widget/RestrictedWidget.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import Widget from './Widget'; + +function RestrictedWidget(props) { + return ( + +
+
+

+

+ This widget requires access to a data source you don't have access to. +

+
+
+
+ ); +} + +export default RestrictedWidget; diff --git a/client/app/components/dashboards/dashboard-widget/TextboxWidget.jsx b/client/app/components/dashboards/dashboard-widget/TextboxWidget.jsx new file mode 100644 index 0000000000..6ab76a7ea9 --- /dev/null +++ b/client/app/components/dashboards/dashboard-widget/TextboxWidget.jsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { markdown } from 'markdown'; +import Menu from 'antd/lib/menu'; +import HtmlContent from '@/components/HtmlContent'; +import TextboxDialog from '@/components/dashboards/TextboxDialog'; +import Widget from './Widget'; + +function TextboxWidget(props) { + const { widget, canEdit } = props; + const [text, setText] = useState(widget.text); + + const editTextBox = () => { + TextboxDialog.showModal({ + text: widget.text, + onConfirm: (newText) => { + widget.text = newText; + setText(newText); + return widget.save(); + }, + }); + }; + + const TextboxMenuOptions = [ + Edit, + ]; + + if (!widget.width) { + return null; + } + + return ( + + + {markdown.toHTML(text || '')} + + + ); +} + +TextboxWidget.propTypes = { + widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + canEdit: PropTypes.bool, +}; + +TextboxWidget.defaultProps = { + canEdit: false, +}; + +export default TextboxWidget; diff --git a/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx b/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx new file mode 100644 index 0000000000..ddf431c12f --- /dev/null +++ b/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx @@ -0,0 +1,304 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { compact, isEmpty, invoke } from 'lodash'; +import { markdown } from 'markdown'; +import cx from 'classnames'; +import Menu from 'antd/lib/menu'; +import { currentUser } from '@/services/auth'; +import recordEvent from '@/services/recordEvent'; +import { formatDateTime } from '@/filters/datetime'; +import HtmlContent from '@/components/HtmlContent'; +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 ExpandedWidgetDialog from '@/components/dashboards/ExpandedWidgetDialog'; +import EditParameterMappingsDialog from '@/components/dashboards/EditParameterMappingsDialog'; +import { VisualizationRenderer } from '@/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([ + + {!isQueryResultEmpty ? ( + + Download as CSV File + + ) : 'Download as CSV File'} + , + + {!isQueryResultEmpty ? ( + + Download as Excel File + + ) : 'Download as Excel File'} + , + ((canViewQuery || canEditParameters) && ), + canViewQuery && ( + + View Query + + ), + (canEditParameters && ( + + Edit Parameters + + )), + ]); +} + +function RefreshIndicator({ refreshStartedAt }) { + return ( +
+
+ +
+ +
+ ); +} + +RefreshIndicator.propTypes = { refreshStartedAt: Moment.isRequired }; + +function VisualizationWidgetHeader({ widget, refreshStartedAt, parameters, onParametersUpdate }) { + const canViewQuery = currentUser.hasPermission('view_query'); + + return ( + <> + +
+
+

+ +

+ + {markdown.toHTML(widget.getQuery().description || '')} + +
+
+ {!isEmpty(parameters) && ( +
+ +
+ )} + + ); +} + +VisualizationWidgetHeader.propTypes = { + widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + refreshStartedAt: Moment, + parameters: PropTypes.arrayOf(PropTypes.object), + onParametersUpdate: PropTypes.func, +}; + +VisualizationWidgetHeader.defaultProps = { + refreshStartedAt: null, + onParametersUpdate: () => {}, + 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 ( + <> + + {(!isPublic && !!widgetQueryResult) && ( + refreshWidget(1)} + data-test="RefreshButton" + > + {' '} + + + )} + + {' '}{formatDateTime(updatedAt)} + + {isPublic && ( + + {' '} + + )} + + + {!isPublic && ( + refreshWidget(2)} + > + + + )} + + + + + + ); +} + +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, + canEdit: PropTypes.bool, + onLoad: PropTypes.func, + onRefresh: PropTypes.func, + onDelete: PropTypes.func, + onParameterMappingsChange: PropTypes.func, + }; + + static defaultProps = { + filters: [], + isPublic: false, + canEdit: false, + onLoad: () => {}, + onRefresh: () => {}, + onDelete: () => {}, + onParameterMappingsChange: () => {}, + }; + + constructor(props) { + super(props); + this.state = { localParameters: props.widget.getLocalParameters() }; + } + + componentDidMount() { + const { widget, onLoad } = this.props; + recordEvent('view', 'query', widget.visualization.query.id, { dashboard: true }); + recordEvent('view', 'visualization', widget.visualization.id, { dashboard: true }); + onLoad(); + } + + expandWidget = () => { + ExpandedWidgetDialog.showModal({ widget: this.props.widget }); + }; + + editParameterMappings = () => { + const { widget, dashboard, onParameterMappingsChange } = this.props; + EditParameterMappingsDialog.showModal({ + dashboard, + widget, + }).result.then((valuesChanged) => { + // refresh widget if any parameter value has been updated + if (valuesChanged) { + this.refresh(); + } + 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 ( +
+ {widgetQueryResult.getError() && ( +
+ Error running query: {widgetQueryResult.getError()} +
+ )} +
+ ); + case 'done': + return ( +
+ +
+ ); + default: + return ( +
+
+ +
+
+ ); + } + } + + render() { + const { widget, isPublic, canEdit, onRefresh } = this.props; + const { localParameters } = this.state; + const widgetQueryResult = widget.getQueryResult(); + const isRefreshing = widget.loading && !!(widgetQueryResult && widgetQueryResult.getStatus()); + + return ( + + )} + footer={( + + )} + data-refreshing={isRefreshing} + > + {this.renderVisualization()} + + ); + } +} + +export default VisualizationWidget; diff --git a/client/app/components/dashboards/dashboard-widget/Widget.jsx b/client/app/components/dashboards/dashboard-widget/Widget.jsx new file mode 100644 index 0000000000..780e6487b1 --- /dev/null +++ b/client/app/components/dashboards/dashboard-widget/Widget.jsx @@ -0,0 +1,139 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { isEmpty } from 'lodash'; +import Dropdown from 'antd/lib/dropdown'; +import Modal from 'antd/lib/modal'; +import Menu from 'antd/lib/menu'; +import recordEvent from '@/services/recordEvent'; +import { Moment } from '@/components/proptypes'; + +import './Widget.less'; + +function WidgetDropdownButton({ extraOptions, showDeleteOption, onDelete }) { + const WidgetMenu = ( + + {extraOptions} + {(showDeleteOption && extraOptions) && } + {showDeleteOption && Remove from Dashboard} + + ); + + return ( +
+ + + + + +
+ ); +} + +WidgetDropdownButton.propTypes = { + extraOptions: PropTypes.node, + showDeleteOption: PropTypes.bool, + onDelete: PropTypes.func, +}; + +WidgetDropdownButton.defaultProps = { + extraOptions: null, + showDeleteOption: false, + onDelete: () => {}, +}; + +function WidgetDeleteButton({ onClick }) { + return ( +
+ + + +
+ ); +} + +WidgetDeleteButton.propTypes = { onClick: PropTypes.func }; +WidgetDeleteButton.defaultProps = { onClick: () => {} }; + +class Widget extends React.Component { + static propTypes = { + widget: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types + className: PropTypes.string, + children: PropTypes.node, + header: PropTypes.node, + footer: PropTypes.node, + canEdit: PropTypes.bool, + isPublic: PropTypes.bool, + refreshStartedAt: Moment, + menuOptions: PropTypes.node, + onDelete: PropTypes.func, + }; + + static defaultProps = { + className: '', + children: null, + header: null, + footer: null, + canEdit: false, + isPublic: false, + refreshStartedAt: null, + menuOptions: null, + onDelete: () => {}, + }; + + componentDidMount() { + const { widget } = this.props; + recordEvent('view', 'widget', widget.id); + } + + deleteWidget = () => { + const { widget, onDelete } = this.props; + + Modal.confirm({ + title: 'Delete Widget', + content: 'Are you sure you want to remove this widget from the dashboard?', + okText: 'Delete', + okType: 'danger', + onOk: () => widget.delete().then(onDelete), + maskClosable: true, + autoFocusButton: null, + }); + }; + + render() { + const { widget, className, children, header, footer, canEdit, isPublic, + onDelete, menuOptions, ...otherProps } = this.props; + const showDropdownButton = !isPublic && (canEdit || !isEmpty(menuOptions)); + return ( +
+
+
+ {showDropdownButton && ( + + )} + {canEdit && } +
+
+ {header} +
+ {children} + {footer && ( +
+ {footer} +
+ )} +
+
+ ); + } +} + +export default Widget; diff --git a/client/app/components/dashboards/widget.less b/client/app/components/dashboards/dashboard-widget/Widget.less similarity index 75% rename from client/app/components/dashboards/widget.less rename to client/app/components/dashboards/dashboard-widget/Widget.less index d3e04b7520..baef18ac7e 100644 --- a/client/app/components/dashboards/widget.less +++ b/client/app/components/dashboards/dashboard-widget/Widget.less @@ -1,54 +1,54 @@ -@import '../../assets/less/inc/variables'; +@import '../../../assets/less/inc/variables'; .tile .t-header .th-title a.query-link { color: rgba(0, 0, 0, 0.5); } -visualization-name:empty + span { - color: rgba(0, 0, 0, 0.8); -} - -visualization-name { - font-size: 15px; - font-weight: 500; - color: rgba(0, 0, 0, 0.8); - - &:after { - content: "−"; - margin-left: 5px; - } - - &:empty:after { - content: none; - } -} - .th-title p.hidden-print { margin-bottom: 0; } .widget-wrapper { + .widget-actions { + position: absolute; + top: 0; + right: 0; + z-index: 1; + + .action { + font-size: 24px; + cursor: pointer; + line-height: 100%; + display: block; + padding: 4px 10px 3px; + } + + .action:hover { + background-color: rgba(0, 0, 0, 0.1); + } + } + .parameter-container { margin: 0 15px; } - + .body-container { display: flex; flex-direction: column; align-items: stretch; - + .body-row { flex: 0 1 auto; } - + .body-row-auto { flex: 1 1 auto; } } - + .spinner-container { position: relative; - + .spinner { display: flex; align-items: center; @@ -61,31 +61,12 @@ visualization-name { height: 100%; } } - - .dropdown-header { - padding: 0; - - .actions { - position: static; - } - } - - .t-header.widget { - .dropdown { - margin-top: -15px; - margin-right: -15px; - - .actions { - position: static; - } - } - } - + .scrollbox:empty { padding: 0 !important; font-size: 1px !important; } - + .widget-text { :first-child { margin-top: 0; @@ -103,23 +84,23 @@ visualization-name { .widget-menu-remove { display: block; } - + a.query-link { pointer-events: none; cursor: move; } - + .th-title { cursor: move; } - + .refresh-indicator { transition-duration: 0s; - - rd-timer { + + .rd-timer { display: none; } - + .refresh-indicator-mini(); } } @@ -138,7 +119,7 @@ visualization-name { .refresh-icon { position: relative; - + &:before { content: ""; position: absolute; @@ -161,7 +142,7 @@ visualization-name { } } - rd-timer { + .rd-timer { font-size: 13px; display: inline-block; font-variant-numeric: tabular-nums; @@ -193,7 +174,7 @@ visualization-name { opacity: 0; } - rd-timer { + .rd-timer { transition-delay: 0s; opacity: 1; transform: translateX(0); @@ -270,33 +251,3 @@ visualization-name { } } } - - -// react-grid-layout overrides -.react-grid-item { - - // placeholder color - &.react-grid-placeholder { - border-radius: 3px; - background-color: #E0E6EB; - opacity: 0.5; - } - - // resize placeholder behind widget, the lib's default is above 🤷‍♂️ - &.resizing { - z-index: 3; - } - - // auto-height animation - &.cssTransforms:not(.resizing) { - transition-property: transform, height; // added ", height" - } - - // resize handle size - & > .react-resizable-handle::after { - width: 11px; - height: 11px; - right: 5px; - bottom: 5px; - } -} \ No newline at end of file diff --git a/client/app/components/dashboards/dashboard-widget/index.js b/client/app/components/dashboards/dashboard-widget/index.js new file mode 100644 index 0000000000..e98639caf2 --- /dev/null +++ b/client/app/components/dashboards/dashboard-widget/index.js @@ -0,0 +1,3 @@ +export { default as VisualizationWidget } from './VisualizationWidget'; +export { default as TextboxWidget } from './TextboxWidget'; +export { default as RestrictedWidget } from './RestrictedWidget'; diff --git a/client/app/components/dashboards/widget-dialog.html b/client/app/components/dashboards/widget-dialog.html deleted file mode 100644 index 32404bbd8b..0000000000 --- a/client/app/components/dashboards/widget-dialog.html +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/client/app/components/dashboards/widget-dialog.less b/client/app/components/dashboards/widget-dialog.less deleted file mode 100644 index 5168a67581..0000000000 --- a/client/app/components/dashboards/widget-dialog.less +++ /dev/null @@ -1,8 +0,0 @@ -.visualization-title { - font-weight: 500; - font-size: 15px; -} - -body.modal-open .dropdown.open { - z-index: 10000; -} diff --git a/client/app/components/dashboards/widget.html b/client/app/components/dashboards/widget.html deleted file mode 100644 index a23c5f381b..0000000000 --- a/client/app/components/dashboards/widget.html +++ /dev/null @@ -1,123 +0,0 @@ -
-
-
-
- - -
-
- -
- -
-
-

- -

-
-
-
-
- -
-
- -
-
Error running query: {{$ctrl.widget.getQueryResult().getError()}}
-
-
- -
-
-
- -
-
- -
- - - - - - - - - - {{$ctrl.widget.getQueryResult().getUpdatedAt() | dateTime}} - - - - - - - -
-
- -
-
-
-

-

- This widget requires access to a data source you don't have access to. -

-
-
-
- -
-
- - -
-
-
-
diff --git a/client/app/components/dashboards/widget.js b/client/app/components/dashboards/widget.js deleted file mode 100644 index b51d427f91..0000000000 --- a/client/app/components/dashboards/widget.js +++ /dev/null @@ -1,137 +0,0 @@ -import { filter } from 'lodash'; -import { angular2react } from 'angular2react'; -import template from './widget.html'; -import TextboxDialog from '@/components/dashboards/TextboxDialog'; -import widgetDialogTemplate from './widget-dialog.html'; -import EditParameterMappingsDialog from '@/components/dashboards/EditParameterMappingsDialog'; -import './widget.less'; -import './widget-dialog.less'; - -const WidgetDialog = { - template: widgetDialogTemplate, - bindings: { - resolve: '<', - close: '&', - dismiss: '&', - }, - controller() { - this.widget = this.resolve.widget; - }, -}; - -export let DashboardWidget = null; // eslint-disable-line import/no-mutable-exports - -function DashboardWidgetCtrl($scope, $location, $uibModal, $window, $rootScope, $timeout, Events, currentUser) { - this.canViewQuery = currentUser.hasPermission('view_query'); - - this.editTextBox = () => { - TextboxDialog.showModal({ - dashboard: this.dashboard, - text: this.widget.text, - onConfirm: (text) => { - this.widget.text = text; - return this.widget.save(); - }, - }); - }; - - this.expandVisualization = () => { - $uibModal.open({ - component: 'widgetDialog', - resolve: { - widget: this.widget, - }, - size: 'lg', - }); - }; - - this.hasParameters = () => this.widget.query.getParametersDefs().length > 0; - - this.editParameterMappings = () => { - EditParameterMappingsDialog.showModal({ - dashboard: this.dashboard, - widget: this.widget, - }).result.then((valuesChanged) => { - this.localParameters = null; - - // refresh widget if any parameter value has been updated - if (valuesChanged) { - $timeout(() => this.refresh()); - } - $scope.$applyAsync(); - $rootScope.$broadcast('dashboard.update-parameters'); - }); - }; - - this.localParametersDefs = () => { - if (!this.localParameters) { - this.localParameters = filter( - this.widget.getParametersDefs(), - param => !this.widget.isStaticParam(param), - ); - } - return this.localParameters; - }; - - this.deleteWidget = () => { - if (!$window.confirm(`Are you sure you want to remove "${this.widget.getName()}" from the dashboard?`)) { - return; - } - - this.widget.delete().then(() => { - if (this.deleted) { - this.deleted({}); - } - }); - }; - - Events.record('view', 'widget', this.widget.id); - - this.load = (refresh = false) => { - const maxAge = $location.search().maxAge; - return this.widget.load(refresh, maxAge); - }; - - this.forceRefresh = () => this.load(true); - - this.refresh = (buttonId) => { - this.refreshClickButtonId = buttonId; - this.load(true).finally(() => { - this.refreshClickButtonId = undefined; - }); - }; - - if (this.widget.visualization) { - Events.record('view', 'query', this.widget.visualization.query.id, { dashboard: true }); - Events.record('view', 'visualization', this.widget.visualization.id, { dashboard: true }); - - this.type = 'visualization'; - this.load(); - } else if (this.widget.restricted) { - this.type = 'restricted'; - } else { - this.type = 'textbox'; - } -} - -const DashboardWidgetOptions = { - template, - controller: DashboardWidgetCtrl, - bindings: { - widget: '<', - public: '<', - dashboard: '<', - filters: '<', - deleted: '<', - }, -}; - -export default function init(ngModule) { - ngModule.component('widgetDialog', WidgetDialog); - ngModule.component('dashboardWidget', DashboardWidgetOptions); - ngModule.run(['$injector', ($injector) => { - DashboardWidget = angular2react('dashboardWidget ', DashboardWidgetOptions, $injector); - }]); -} - -init.init = true; diff --git a/client/app/components/query-link.js b/client/app/components/query-link.js deleted file mode 100644 index 307675e312..0000000000 --- a/client/app/components/query-link.js +++ /dev/null @@ -1,32 +0,0 @@ -export default function init(ngModule) { - ngModule.component('queryLink', { - bindings: { - query: '<', - visualization: '<', - readonly: '<', - }, - template: ` - - - {{$ctrl.query.name}} - - `, - controller() { - this.getUrl = () => { - let hash = null; - if (this.visualization) { - if (this.visualization.type === 'TABLE') { - // link to hard-coded table tab instead of the (hidden) visualization tab - hash = 'table'; - } else { - hash = this.visualization.id; - } - } - - return this.query.getUrl(false, hash); - }; - }, - }); -} - -init.init = true; diff --git a/client/app/lib/hooks/useQueryResult.js b/client/app/lib/hooks/useQueryResult.js index 3918dd2fb9..43b420c223 100644 --- a/client/app/lib/hooks/useQueryResult.js +++ b/client/app/lib/hooks/useQueryResult.js @@ -1,10 +1,11 @@ import { useState, useEffect } from 'react'; +import { invoke } from 'lodash'; function getQueryResultData(queryResult) { return { - columns: (queryResult && queryResult.getColumns()) || [], - rows: (queryResult && queryResult.getData()) || [], - filters: (queryResult && queryResult.getFilters()) || [], + columns: invoke(queryResult, 'getColumns') || [], + rows: invoke(queryResult, 'getData') || [], + filters: invoke(queryResult, 'getFilters') || [], }; } diff --git a/client/app/pages/dashboards/dashboard.html b/client/app/pages/dashboards/dashboard.html index cc89ae3e61..c594824840 100644 --- a/client/app/pages/dashboards/dashboard.html +++ b/client/app/pages/dashboards/dashboard.html @@ -110,7 +110,10 @@

is-editing="$ctrl.layoutEditing && !$ctrl.isGridDisabled" on-layout-change="$ctrl.onLayoutChange" on-breakpoint-change="$ctrl.onBreakpointChanged" + on-load-widget="$ctrl.loadWidget" + on-refresh-widget="$ctrl.refreshWidget" on-remove-widget="$ctrl.removeWidget" + on-parameter-mappings-change="$ctrl.extractGlobalParameters" /> diff --git a/client/app/pages/dashboards/dashboard.js b/client/app/pages/dashboards/dashboard.js index e2ed937b50..8346d929de 100644 --- a/client/app/pages/dashboards/dashboard.js +++ b/client/app/pages/dashboards/dashboard.js @@ -133,9 +133,19 @@ function DashboardCtrl( this.globalParameters = this.dashboard.getParametersDefs(); }; - $scope.$on('dashboard.update-parameters', () => { - this.extractGlobalParameters(); - }); + // ANGULAR_REMOVE_ME This forces Widgets re-rendering + // use state when Dashboard is migrated to React + this.forceDashboardGridReload = () => { + this.dashboard.widgets = [...this.dashboard.widgets]; + }; + + this.loadWidget = (widget, forceRefresh = false) => { + widget.getParametersDefs(); // Force widget to read parameters values from URL + this.forceDashboardGridReload(); + return widget.load(forceRefresh).finally(this.forceDashboardGridReload); + }; + + this.refreshWidget = widget => this.loadWidget(widget, true); const collectFilters = (dashboard, forceRefresh, updatedParameters = []) => { const affectedWidgets = updatedParameters.length > 0 ? this.dashboard.widgets.filter( @@ -146,10 +156,7 @@ function DashboardCtrl( ), ) : this.dashboard.widgets; - const queryResultPromises = _.compact(affectedWidgets.map((widget) => { - widget.getParametersDefs(); // Force widget to read parameters values from URL - return widget.load(forceRefresh); - })); + const queryResultPromises = _.compact(affectedWidgets.map(widget => this.loadWidget(widget, forceRefresh))); return $q.all(queryResultPromises).then((queryResults) => { this.filters = collectDashboardFilters(dashboard, queryResults, $location.search()); diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less index 1c199b886f..4ea608e0d6 100644 --- a/client/app/pages/dashboards/dashboard.less +++ b/client/app/pages/dashboards/dashboard.less @@ -23,7 +23,7 @@ } .pivot-table-renderer > table, - visualization-renderer > .visualization-renderer-wrapper { + .visualization-renderer > .visualization-renderer-wrapper { overflow: visible; } @@ -57,7 +57,7 @@ } .dashboard-widget-wrapper:not(.widget-auto-height-enabled) { - visualization-renderer { + .visualization-renderer { display: flex; flex-direction: column; position: absolute; diff --git a/client/app/pages/dashboards/public-dashboard-page.html b/client/app/pages/dashboards/public-dashboard-page.html index 7253244634..2c1d251d3f 100644 --- a/client/app/pages/dashboards/public-dashboard-page.html +++ b/client/app/pages/dashboards/public-dashboard-page.html @@ -17,6 +17,8 @@ filters="$ctrl.filters" is-editing="false" is-public="true" + on-load-widget="$ctrl.loadWidget" + on-refresh-widget="$ctrl.refreshWidget" /> diff --git a/client/app/pages/dashboards/public-dashboard-page.js b/client/app/pages/dashboards/public-dashboard-page.js index 453cc01d4f..6152a9ea5e 100644 --- a/client/app/pages/dashboards/public-dashboard-page.js +++ b/client/app/pages/dashboards/public-dashboard-page.js @@ -34,11 +34,25 @@ const PublicDashboardPage = { const refreshRate = Math.max(30, parseFloat($location.search().refresh)); + // ANGULAR_REMOVE_ME This forces Widgets re-rendering + // use state when PublicDashboard is migrated to React + this.forceDashboardGridReload = () => { + this.dashboard.widgets = [...this.dashboard.widgets]; + }; + + this.loadWidget = (widget, forceRefresh = false) => { + widget.getParametersDefs(); // Force widget to read parameters values from URL + this.forceDashboardGridReload(); + return widget.load(forceRefresh).finally(this.forceDashboardGridReload); + }; + + this.refreshWidget = widget => this.loadWidget(widget, true); + this.refreshDashboard = () => { loadDashboard($http, $route).then((data) => { this.dashboard = new Dashboard(data); this.dashboard.widgets = Dashboard.prepareDashboardWidgets(this.dashboard.widgets); - this.dashboard.widgets.forEach(widget => widget.load(!!refreshRate)); + this.dashboard.widgets.forEach(widget => this.loadWidget(widget, !!refreshRate)); this.filters = []; // TODO: implement (@/services/dashboard.js:collectDashboardFilters) this.filtersOnChange = (allFilters) => { this.filters = allFilters; diff --git a/client/app/services/query.js b/client/app/services/query.js index b892e116b7..9b14581f6f 100644 --- a/client/app/services/query.js +++ b/client/app/services/query.js @@ -349,10 +349,11 @@ export class Parameter { } toUrlParams() { + const prefix = this.urlPrefix; if (this.isEmpty) { - return {}; + return { [`${prefix}${this.name}`]: null }; } - const prefix = this.urlPrefix; + if (isDateRangeParameter(this.type) && isObject(this.value)) { return { [`${prefix}${this.name}.start`]: this.value.start, diff --git a/client/app/services/widget.js b/client/app/services/widget.js index 8816c7762f..1cd5da4f14 100644 --- a/client/app/services/widget.js +++ b/client/app/services/widget.js @@ -5,6 +5,12 @@ import { registeredVisualizations } from '@/visualizations'; export let Widget = null; // eslint-disable-line import/no-mutable-exports +export const WidgetTypeEnum = { + TEXTBOX: 'textbox', + VISUALIZATION: 'visualization', + RESTRICTED: 'restricted', +}; + function calculatePositionOptions(widget) { widget.width = 1; // Backward compatibility, user on back-end @@ -93,6 +99,15 @@ function WidgetFactory($http, $location, Query) { } } + get type() { + if (this.visualization) { + return WidgetTypeEnum.VISUALIZATION; + } else if (this.restricted) { + return WidgetTypeEnum.RESTRICTED; + } + return WidgetTypeEnum.TEXTBOX; + } + getQuery() { if (!this.query && this.visualization) { this.query = new Query(this.visualization.query); @@ -239,6 +254,13 @@ function WidgetFactory($http, $location, Query) { return this.options.parameterMappings; } + + getLocalParameters() { + return filter( + this.getParametersDefs(), + param => !this.isStaticParam(param), + ); + } } return WidgetService; diff --git a/client/app/visualizations/VisualizationName.jsx b/client/app/visualizations/VisualizationName.jsx index 83c67765cc..ca536961df 100644 --- a/client/app/visualizations/VisualizationName.jsx +++ b/client/app/visualizations/VisualizationName.jsx @@ -1,15 +1,16 @@ +import React from 'react'; import { react2angular } from 'react2angular'; import { VisualizationType, registeredVisualizations } from './index'; +import './VisualizationName.less'; + export function VisualizationName({ visualization }) { const config = registeredVisualizations[visualization.type]; - if (config) { - if (visualization.name !== config.name) { - return visualization.name; - } - } - - return null; + return ( + + {config && (visualization.name !== config.name) ? visualization.name : null} + + ); } VisualizationName.propTypes = { diff --git a/client/app/visualizations/VisualizationName.less b/client/app/visualizations/VisualizationName.less new file mode 100644 index 0000000000..40f0b28ebc --- /dev/null +++ b/client/app/visualizations/VisualizationName.less @@ -0,0 +1,18 @@ +.visualization-name:empty + span { + color: rgba(0, 0, 0, 0.8); +} + +.visualization-name { + font-size: 15px; + font-weight: 500; + color: rgba(0, 0, 0, 0.8); + + &:after { + content: "−"; + margin-left: 5px; + } + + &:empty:after { + content: none; + } +} diff --git a/client/app/visualizations/VisualizationRenderer.jsx b/client/app/visualizations/VisualizationRenderer.jsx index 2e4d95732c..4e5d524587 100644 --- a/client/app/visualizations/VisualizationRenderer.jsx +++ b/client/app/visualizations/VisualizationRenderer.jsx @@ -58,7 +58,7 @@ export function VisualizationRenderer(props) { lastOptions.current = options; return ( - +
{showFilters && }
- +
); } diff --git a/client/cypress/integration/dashboard/textbox_spec.js b/client/cypress/integration/dashboard/textbox_spec.js index 18460e2b6d..610ac97d54 100644 --- a/client/cypress/integration/dashboard/textbox_spec.js +++ b/client/cypress/integration/dashboard/textbox_spec.js @@ -12,6 +12,10 @@ describe('Textbox', () => { }); }); + const confirmDeletionInModal = () => { + cy.get('.ant-modal .ant-btn').contains('Delete').click({ force: true }); + }; + it('adds textbox', function () { cy.visit(this.dashboardUrl); editDashboard(); @@ -21,7 +25,7 @@ describe('Textbox', () => { }); cy.contains('button', 'Add to Dashboard').click(); cy.getByTestId('TextboxDialog').should('not.exist'); - cy.get('.textbox').should('exist'); + cy.get('.widget-text').should('exist'); }); it('removes textbox by X button', function () { @@ -31,9 +35,11 @@ describe('Textbox', () => { cy.getByTestId(elTestId) .within(() => { - cy.get('.widget-menu-remove').click(); - }) - .should('not.exist'); + cy.getByTestId('WidgetDeleteButton').click(); + }); + + confirmDeletionInModal(); + cy.getByTestId(elTestId).should('not.exist'); }); }); @@ -42,15 +48,15 @@ describe('Textbox', () => { cy.visit(this.dashboardUrl); cy.getByTestId(elTestId) .within(() => { - cy.get('.widget-menu-regular') - .click({ force: true }) - .within(() => { - cy.get('li a') - .contains('Remove From Dashboard') - .click({ force: true }); - }); - }) - .should('not.exist'); + cy.getByTestId('WidgetDropdownButton') + .click(); + }); + cy.getByTestId('WidgetDropdownButtonMenu') + .contains('Remove from Dashboard') + .click(); + + confirmDeletionInModal(); + cy.getByTestId(elTestId).should('not.exist'); }); }); @@ -70,8 +76,10 @@ describe('Textbox', () => { cy.getByTestId(elTestId1) .as('textbox1') .within(() => { - cy.get('.widget-menu-remove').click(); + cy.getByTestId('WidgetDeleteButton').click(); }); + + confirmDeletionInModal(); cy.get('@textbox1').should('not.exist'); // remove 2nd textbox and make sure it's gone @@ -79,8 +87,10 @@ describe('Textbox', () => { .as('textbox2') .within(() => { // unclickable https://github.com/getredash/redash/issues/3202 - cy.get('.widget-menu-remove').click(); + cy.getByTestId('WidgetDeleteButton').click(); }); + + confirmDeletionInModal(); cy.get('@textbox2').should('not.exist'); // <-- fails because of the bug }); }); @@ -91,15 +101,14 @@ describe('Textbox', () => { cy.getByTestId(elTestId) .as('textboxEl') .within(() => { - cy.get('.widget-menu-regular') - .click({ force: true }) - .within(() => { - cy.get('li a') - .contains('Edit') - .click({ force: true }); - }); + cy.getByTestId('WidgetDropdownButton') + .click(); }); + cy.getByTestId('WidgetDropdownButtonMenu') + .contains('Edit') + .click(); + const newContent = '[edited]'; cy.getByTestId('TextboxDialog') .should('exist') diff --git a/client/cypress/integration/dashboard/widget_spec.js b/client/cypress/integration/dashboard/widget_spec.js index 7a42bbd1e6..e726e7c3cd 100644 --- a/client/cypress/integration/dashboard/widget_spec.js +++ b/client/cypress/integration/dashboard/widget_spec.js @@ -12,6 +12,10 @@ describe('Widget', () => { }); }); + const confirmDeletionInModal = () => { + cy.get('.ant-modal .ant-btn').contains('Delete').click({ force: true }); + }; + it('adds widget', function () { createQuery().then(({ id: queryId }) => { cy.visit(this.dashboardUrl); @@ -32,9 +36,11 @@ describe('Widget', () => { editDashboard(); cy.getByTestId(elTestId) .within(() => { - cy.get('.widget-menu-remove').click(); - }) - .should('not.exist'); + cy.getByTestId('WidgetDeleteButton').click(); + }); + + confirmDeletionInModal(); + cy.getByTestId(elTestId).should('not.exist'); }); });