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 = (
+
+ );
+
+ 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 @@
-
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 (
-
+
);
}
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');
});
});