From 2771fbe62425acd7091e5df8ed713bbba7d6f804 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 1 Oct 2019 14:24:18 -0300 Subject: [PATCH 01/39] Initial React Rendering with useDashboard --- .../components/dashboards/DashboardGrid.jsx | 2 +- .../pages/dashboards/PublicDashboardPage.jsx | 115 ++++++++++++++++++ .../pages/dashboards/PublicDashboardPage.less | 16 +++ .../pages/dashboards/public-dashboard-page.js | 2 +- client/app/pages/dashboards/useDashboard.js | 60 +++++++++ client/app/services/dashboard.js | 5 + 6 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 client/app/pages/dashboards/PublicDashboardPage.jsx create mode 100644 client/app/pages/dashboards/PublicDashboardPage.less create mode 100644 client/app/pages/dashboards/useDashboard.js diff --git a/client/app/components/dashboards/DashboardGrid.jsx b/client/app/components/dashboards/DashboardGrid.jsx index c16c85e65d..47b319dd8d 100644 --- a/client/app/components/dashboards/DashboardGrid.jsx +++ b/client/app/components/dashboards/DashboardGrid.jsx @@ -34,7 +34,7 @@ const WidgetType = PropTypes.shape({ const SINGLE = 'single-column'; const MULTI = 'multi-column'; -class DashboardGrid extends React.Component { +export class DashboardGrid extends React.Component { static propTypes = { isEditing: PropTypes.bool.isRequired, isPublic: PropTypes.bool, diff --git a/client/app/pages/dashboards/PublicDashboardPage.jsx b/client/app/pages/dashboards/PublicDashboardPage.jsx new file mode 100644 index 0000000000..64e1b867c3 --- /dev/null +++ b/client/app/pages/dashboards/PublicDashboardPage.jsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { isEmpty } from 'lodash'; +import PropTypes from 'prop-types'; +import { react2angular } from 'react2angular'; +import { BigMessage } from '@/components/BigMessage'; +import { PageHeader } from '@/components/PageHeader'; +import { Parameters } from '@/components/Parameters'; +import { DashboardGrid } from '@/components/dashboards/DashboardGrid'; +import { Filters } from '@/components/Filters'; +import { Dashboard } from '@/services/dashboard'; +import { $route as ngRoute } from '@/services/ng'; +import PromiseRejectionError from '@/lib/promise-rejection-error'; +import logoUrl from '@/assets/images/redash_icon_small.png'; +import useDashboard from './useDashboard'; + +import './PublicDashboardPage.less'; + + +function PublicDashboard({ dashboard }) { + const { globalParameters, filters, setFilters, refreshDashboard, + widgets, loadWidget, refreshWidget } = useDashboard(dashboard); + + return ( +
+ + {!isEmpty(globalParameters) && ( +
+ +
+ )} + {!isEmpty(filters) && ( +
+ +
+ )} +
+ +
+
+ ); +} + +PublicDashboard.propTypes = { + dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +class PublicDashboardPage extends React.Component { + state = { + loading: true, + dashboard: null, + }; + + componentDidMount() { + Dashboard.getByToken({ token: ngRoute.current.params.token }).$promise + .then(dashboard => this.setState({ dashboard, loading: false })) + .catch((error) => { throw new PromiseRejectionError(error); }); + } + + render() { + const { loading, dashboard } = this.state; + return ( +
+ {loading ? ( +
+ +
+ ) : ( + + )} + +
+ ); + } +} + +export default function init(ngModule) { + ngModule.component('publicDashboardPage', react2angular(PublicDashboardPage)); + + function session($route, Auth) { + const token = $route.current.params.token; + Auth.setApiKey(token); + return Auth.loadConfig(); + } + + ngModule.config(($routeProvider) => { + $routeProvider.when('/public/dashboards/:token', { + template: '', + reloadOnSearch: false, + resolve: { + session, + }, + }); + }); + + return []; +} + +init.init = true; diff --git a/client/app/pages/dashboards/PublicDashboardPage.less b/client/app/pages/dashboards/PublicDashboardPage.less new file mode 100644 index 0000000000..b13605a771 --- /dev/null +++ b/client/app/pages/dashboards/PublicDashboardPage.less @@ -0,0 +1,16 @@ +.public-dashboard-page { + > .container { + min-height: calc(100vh - 95px); + } + + .loading-message { + display: flex; + align-items: center; + justify-content: center; + } + + #footer { + height: 95px; + text-align: center; + } +} \ No newline at end of file diff --git a/client/app/pages/dashboards/public-dashboard-page.js b/client/app/pages/dashboards/public-dashboard-page.js index 6152a9ea5e..b2aa917e37 100644 --- a/client/app/pages/dashboards/public-dashboard-page.js +++ b/client/app/pages/dashboards/public-dashboard-page.js @@ -95,4 +95,4 @@ export default function init(ngModule) { return []; } -init.init = true; +// init.init = true; diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js new file mode 100644 index 0000000000..60aa126797 --- /dev/null +++ b/client/app/pages/dashboards/useDashboard.js @@ -0,0 +1,60 @@ +import { useState, useEffect, useMemo } from 'react'; +import { isEmpty, includes, compact } from 'lodash'; +import { $location } from '@/services/ng'; +import { collectDashboardFilters } from '@/services/dashboard'; + +function getAffectedWidgets(widgets, updatedParameters = []) { + return !isEmpty(updatedParameters) ? widgets.filter( + widget => Object.values(widget.getParameterMappings()).filter( + ({ type }) => type === 'dashboard-level', + ).some( + ({ mapTo }) => includes(updatedParameters.map(p => p.name), mapTo), + ), + ) : widgets; +} + +function useDashboard(dashboard) { + const [filters, setFilters] = useState([]); + const [widgets, setWidgets] = useState(dashboard.widgets); + const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]); + + const loadWidget = (widget, forceRefresh = false) => { + widget.getParametersDefs(); // Force widget to read parameters values from URL + setWidgets(dashboard.widgets); + return widget.load(forceRefresh).then((result) => { + setWidgets(dashboard.widgets); + return result; + }); + }; + + const refreshWidget = widget => loadWidget(widget, true); + + const collectFilters = (forceRefresh = false, updatedParameters = []) => { + const affectedWidgets = getAffectedWidgets(widgets, updatedParameters); + const queryResultPromises = compact(affectedWidgets.map(widget => loadWidget(widget, forceRefresh))); + + return Promise.all(queryResultPromises).then((queryResults) => { + const updatedFilters = collectDashboardFilters(dashboard, queryResults, $location.search()); + setFilters(updatedFilters); + }); + }; + + const refreshDashboard = updatedParameters => collectFilters(true, updatedParameters); + const loadDashboard = () => collectFilters(); + + useEffect(() => { + loadDashboard(); + }, []); + + return { + widgets, + globalParameters, + filters, + setFilters, + refreshDashboard, + loadWidget, + refreshWidget, + }; +} + +export default useDashboard; diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js index 509ab387cf..a49543f376 100644 --- a/client/app/services/dashboard.js +++ b/client/app/services/dashboard.js @@ -149,6 +149,11 @@ function DashboardService($resource, $http, $location, currentUser) { { slug: '@slug' }, { get: { method: 'GET', transformResponse: transform }, + getByToken: { + method: 'GET', + url: 'api/dashboards/public/:token', + transformResponse: transform, + }, save: { method: 'POST', transformResponse: transform }, query: { method: 'GET', isArray: false, transformResponse: transform }, recent: { From aa66aff9991e5abc4070b614cdbec6689debbf79 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 1 Oct 2019 17:46:07 -0300 Subject: [PATCH 02/39] Make sure widgets refresh + useCallback --- client/app/pages/dashboards/useDashboard.js | 28 ++++++++++----------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 60aa126797..9de6386376 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -1,5 +1,5 @@ -import { useState, useEffect, useMemo } from 'react'; -import { isEmpty, includes, compact } from 'lodash'; +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { isEmpty, includes, compact, map } from 'lodash'; import { $location } from '@/services/ng'; import { collectDashboardFilters } from '@/services/dashboard'; @@ -18,33 +18,31 @@ function useDashboard(dashboard) { const [widgets, setWidgets] = useState(dashboard.widgets); const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]); - const loadWidget = (widget, forceRefresh = false) => { + const loadWidget = useCallback((widget, forceRefresh = false) => { widget.getParametersDefs(); // Force widget to read parameters values from URL - setWidgets(dashboard.widgets); - return widget.load(forceRefresh).then((result) => { - setWidgets(dashboard.widgets); - return result; - }); - }; + setWidgets([...dashboard.widgets]); // TODO: Explore a better way to do this + return widget.load(forceRefresh).then(() => setWidgets([...dashboard.widgets])); + }, [dashboard]); - const refreshWidget = widget => loadWidget(widget, true); + const refreshWidget = useCallback(widget => loadWidget(widget, true), [loadWidget]); - const collectFilters = (forceRefresh = false, updatedParameters = []) => { + const collectFilters = useCallback((forceRefresh = false, updatedParameters = []) => { const affectedWidgets = getAffectedWidgets(widgets, updatedParameters); - const queryResultPromises = compact(affectedWidgets.map(widget => loadWidget(widget, forceRefresh))); + const loadWidgetPromises = compact(affectedWidgets.map(widget => loadWidget(widget, forceRefresh))); - return Promise.all(queryResultPromises).then((queryResults) => { + return Promise.all(loadWidgetPromises).then(() => { + const queryResults = compact(map(widgets, widget => widget.getQueryResult())); const updatedFilters = collectDashboardFilters(dashboard, queryResults, $location.search()); setFilters(updatedFilters); }); - }; + }, [dashboard, widgets, loadWidget]); const refreshDashboard = updatedParameters => collectFilters(true, updatedParameters); const loadDashboard = () => collectFilters(); useEffect(() => { loadDashboard(); - }, []); + }, [dashboard]); return { widgets, From 49dffbae64a2e3bb2e9fbd285adb9c7123661304 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 2 Oct 2019 19:38:05 -0300 Subject: [PATCH 03/39] Rename collectFilters and add refreshRate --- client/app/pages/dashboards/useDashboard.js | 24 +++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 9de6386376..dbb1c60a3a 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; -import { isEmpty, includes, compact, map } from 'lodash'; +import { isEmpty, isNaN, includes, compact, map } from 'lodash'; import { $location } from '@/services/ng'; import { collectDashboardFilters } from '@/services/dashboard'; @@ -13,20 +13,26 @@ function getAffectedWidgets(widgets, updatedParameters = []) { ) : widgets; } +function getRefreshRateFromUrl() { + const refreshRate = parseFloat($location.search().refresh); + return isNaN(refreshRate) ? null : Math.max(30, refreshRate); +} + function useDashboard(dashboard) { const [filters, setFilters] = useState([]); const [widgets, setWidgets] = useState(dashboard.widgets); const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]); + const refreshRate = useMemo(getRefreshRateFromUrl, []); const loadWidget = useCallback((widget, forceRefresh = false) => { widget.getParametersDefs(); // Force widget to read parameters values from URL - setWidgets([...dashboard.widgets]); // TODO: Explore a better way to do this - return widget.load(forceRefresh).then(() => setWidgets([...dashboard.widgets])); + setWidgets([...dashboard.widgets]); + return widget.load(forceRefresh).finally(() => setWidgets([...dashboard.widgets])); }, [dashboard]); const refreshWidget = useCallback(widget => loadWidget(widget, true), [loadWidget]); - const collectFilters = useCallback((forceRefresh = false, updatedParameters = []) => { + const loadDashboard = useCallback((forceRefresh = false, updatedParameters = []) => { const affectedWidgets = getAffectedWidgets(widgets, updatedParameters); const loadWidgetPromises = compact(affectedWidgets.map(widget => loadWidget(widget, forceRefresh))); @@ -37,13 +43,19 @@ function useDashboard(dashboard) { }); }, [dashboard, widgets, loadWidget]); - const refreshDashboard = updatedParameters => collectFilters(true, updatedParameters); - const loadDashboard = () => collectFilters(); + const refreshDashboard = updatedParameters => loadDashboard(true, updatedParameters); useEffect(() => { loadDashboard(); }, [dashboard]); + useEffect(() => { + if (refreshRate) { + const refreshTimer = setInterval(refreshDashboard, refreshRate * 1000); + return () => clearInterval(refreshTimer); + } + }, [refreshRate]); + return { widgets, globalParameters, From 25166352eb6968248c56ead232e0694b4aa6abce Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 2 Oct 2019 21:09:04 -0300 Subject: [PATCH 04/39] Fix error updates not being rendered --- client/app/pages/dashboards/useDashboard.js | 2 +- client/app/services/dashboard.js | 2 +- client/app/services/widget.js | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index dbb1c60a3a..6ced1b65ba 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -36,7 +36,7 @@ function useDashboard(dashboard) { const affectedWidgets = getAffectedWidgets(widgets, updatedParameters); const loadWidgetPromises = compact(affectedWidgets.map(widget => loadWidget(widget, forceRefresh))); - return Promise.all(loadWidgetPromises).then(() => { + return Promise.all(loadWidgetPromises).finally(() => { const queryResults = compact(map(widgets, widget => widget.getQueryResult())); const updatedFilters = collectDashboardFilters(dashboard, queryResults, $location.search()); setFilters(updatedFilters); diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js index a49543f376..86869c5e70 100644 --- a/client/app/services/dashboard.js +++ b/client/app/services/dashboard.js @@ -7,7 +7,7 @@ export let Dashboard = null; // eslint-disable-line import/no-mutable-exports export function collectDashboardFilters(dashboard, queryResults, urlParams) { const filters = {}; _.each(queryResults, (queryResult) => { - const queryFilters = queryResult ? queryResult.getFilters() : []; + const queryFilters = queryResult && queryResult.getFilters ? queryResult.getFilters() : []; _.each(queryFilters, (queryFilter) => { const hasQueryStringValue = _.has(urlParams, queryFilter.name); diff --git a/client/app/services/widget.js b/client/app/services/widget.js index 1cd5da4f14..fe720483b6 100644 --- a/client/app/services/widget.js +++ b/client/app/services/widget.js @@ -146,14 +146,16 @@ function WidgetFactory($http, $location, Query) { } this.queryResult = this.getQuery().getQueryResult(maxAge); - this.queryResult.toPromise() + return this.queryResult.toPromise() .then((result) => { this.loading = false; this.data = result; + return result; }) .catch((error) => { this.loading = false; this.data = error; + return error; }); } From 6cc5091ee5f77e996f32956417cb0b14ccddfdf7 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 3 Oct 2019 10:08:24 -0300 Subject: [PATCH 05/39] Only render widget bottom when queryResults exists --- .../dashboards/dashboard-widget/VisualizationWidget.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx b/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx index b965d028d6..5d9d0fb59f 100644 --- a/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx +++ b/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx @@ -123,10 +123,10 @@ function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) { } }; - return ( + return widgetQueryResult ? ( <> - {(!isPublic && !!widgetQueryResult) && ( + {!isPublic && ( refreshWidget(1)} @@ -162,7 +162,7 @@ function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) { - ); + ) : null; } VisualizationWidgetFooter.propTypes = { From 779616133f3aef03f36ca6fe9e9b3722d1d8f5ce Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 3 Oct 2019 13:10:21 -0300 Subject: [PATCH 06/39] Cleanup --- client/app/pages/dashboards/dashboard.less | 11 --- .../dashboards/public-dashboard-page.html | 33 ------- .../pages/dashboards/public-dashboard-page.js | 98 ------------------- 3 files changed, 142 deletions(-) delete mode 100644 client/app/pages/dashboards/public-dashboard-page.html delete mode 100644 client/app/pages/dashboards/public-dashboard-page.js diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less index 47cd402aac..09b4f0fb3f 100644 --- a/client/app/pages/dashboards/dashboard.less +++ b/client/app/pages/dashboards/dashboard.less @@ -206,17 +206,6 @@ } } -public-dashboard-page { - > .container { - min-height: calc(100vh - 95px); - } - - #footer { - height: 95px; - text-align: center; - } -} - /**** grid bg - based on 6 cols, 35px rows and 15px spacing ****/ diff --git a/client/app/pages/dashboards/public-dashboard-page.html b/client/app/pages/dashboards/public-dashboard-page.html deleted file mode 100644 index 2c1d251d3f..0000000000 --- a/client/app/pages/dashboards/public-dashboard-page.html +++ /dev/null @@ -1,33 +0,0 @@ -
- - -
- -
- -
- -
- -
- -
-
- - - - diff --git a/client/app/pages/dashboards/public-dashboard-page.js b/client/app/pages/dashboards/public-dashboard-page.js deleted file mode 100644 index b2aa917e37..0000000000 --- a/client/app/pages/dashboards/public-dashboard-page.js +++ /dev/null @@ -1,98 +0,0 @@ -import PromiseRejectionError from '@/lib/promise-rejection-error'; -import logoUrl from '@/assets/images/redash_icon_small.png'; -import template from './public-dashboard-page.html'; -import dashboardGridOptions from '@/config/dashboard-grid-options'; -import './dashboard.less'; - -function loadDashboard($http, $route) { - const token = $route.current.params.token; - return $http.get(`api/dashboards/public/${token}`).then(response => response.data); -} - -const PublicDashboardPage = { - template, - bindings: { - dashboard: '<', - }, - controller($scope, $timeout, $location, $http, $route, Dashboard) { - 'ngInject'; - - this.filters = []; - - this.dashboardGridOptions = Object.assign({}, dashboardGridOptions, { - resizable: { enabled: false }, - draggable: { enabled: false }, - }); - - this.logoUrl = logoUrl; - this.public = true; - this.globalParameters = []; - - this.extractGlobalParameters = () => { - this.globalParameters = this.dashboard.getParametersDefs(); - }; - - 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 => this.loadWidget(widget, !!refreshRate)); - this.filters = []; // TODO: implement (@/services/dashboard.js:collectDashboardFilters) - this.filtersOnChange = (allFilters) => { - this.filters = allFilters; - $scope.$applyAsync(); - }; - - this.extractGlobalParameters(); - }).catch((error) => { - throw new PromiseRejectionError(error); - }); - - if (refreshRate) { - $timeout(this.refreshDashboard, refreshRate * 1000.0); - } - }; - - this.refreshDashboard(); - }, -}; - -export default function init(ngModule) { - ngModule.component('publicDashboardPage', PublicDashboardPage); - - function session($http, $route, Auth) { - const token = $route.current.params.token; - Auth.setApiKey(token); - return Auth.loadConfig(); - } - - ngModule.config(($routeProvider) => { - $routeProvider.when('/public/dashboards/:token', { - template: '', - reloadOnSearch: false, - resolve: { - session, - }, - }); - }); - - return []; -} - -// init.init = true; From ecd55f44cc19393eb28084a464afe84570012906 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 3 Oct 2019 13:21:19 -0300 Subject: [PATCH 07/39] Add useCallback to refreshDashboard --- client/app/pages/dashboards/useDashboard.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 6ced1b65ba..29192636ce 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -43,7 +43,10 @@ function useDashboard(dashboard) { }); }, [dashboard, widgets, loadWidget]); - const refreshDashboard = updatedParameters => loadDashboard(true, updatedParameters); + const refreshDashboard = useCallback( + updatedParameters => loadDashboard(true, updatedParameters), + [loadDashboard], + ); useEffect(() => { loadDashboard(); From bce0d971e6385954505194625fd3167a342d80fe Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 3 Oct 2019 15:19:37 -0300 Subject: [PATCH 08/39] Make sure Promise.all have all promises done --- client/app/pages/dashboards/useDashboard.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 29192636ce..287e44f3f6 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -34,9 +34,11 @@ function useDashboard(dashboard) { const loadDashboard = useCallback((forceRefresh = false, updatedParameters = []) => { const affectedWidgets = getAffectedWidgets(widgets, updatedParameters); - const loadWidgetPromises = compact(affectedWidgets.map(widget => loadWidget(widget, forceRefresh))); + const loadWidgetPromises = compact( + affectedWidgets.map(widget => loadWidget(widget, forceRefresh).catch(error => error)), + ); - return Promise.all(loadWidgetPromises).finally(() => { + return Promise.all(loadWidgetPromises).then(() => { const queryResults = compact(map(widgets, widget => widget.getQueryResult())); const updatedFilters = collectDashboardFilters(dashboard, queryResults, $location.search()); setFilters(updatedFilters); From d55cbff33a082e14ac9536c6a1b7ed90b846e7cd Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 8 Oct 2019 16:27:42 -0300 Subject: [PATCH 09/39] Start migrating Dashoard to React - initial rendering - some actions - temporary updated less file --- client/app/assets/less/inc/base.less | 4 - client/app/assets/less/redash/query.less | 11 + client/app/components/FavoritesControl.jsx | 2 +- .../components/dashboards/dashboard-grid.less | 115 +++++++++ client/app/pages/dashboards/DashboardPage.jsx | 226 ++++++++++++++++++ .../app/pages/dashboards/DashboardPage.less | 33 +++ client/app/pages/dashboards/dashboard.js | 6 +- client/app/pages/dashboards/dashboard.less | 117 +-------- client/app/pages/dashboards/useDashboard.js | 65 ++++- 9 files changed, 450 insertions(+), 129 deletions(-) create mode 100644 client/app/pages/dashboards/DashboardPage.jsx create mode 100644 client/app/pages/dashboards/DashboardPage.less diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index 971aa72409..2552fa8f55 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -227,10 +227,6 @@ text.slicetext { } // page -.page-header--new .btn-favourite, .page-header--new .btn-archive { - font-size: 19px; - } - .page-title { display: flex; align-items: center; diff --git a/client/app/assets/less/redash/query.less b/client/app/assets/less/redash/query.less index 4c73715ee9..ef4e4735d8 100644 --- a/client/app/assets/less/redash/query.less +++ b/client/app/assets/less/redash/query.less @@ -245,6 +245,17 @@ edit-in-place p.editable:hover { margin-left: 15px; margin-right: 15px; } + + .tags-control a { + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + + &:hover { + .tags-control a { + opacity: 1; + } + } } a.label-tag { diff --git a/client/app/components/FavoritesControl.jsx b/client/app/components/FavoritesControl.jsx index 9496737363..c1039edd74 100644 --- a/client/app/components/FavoritesControl.jsx +++ b/client/app/components/FavoritesControl.jsx @@ -38,7 +38,7 @@ export class FavoritesControl extends React.Component { return ( this.toggleItem(event, item, onChange)} >
@@ -147,7 +148,9 @@ function DashboardHeader({ dashboardOptions }) { )} - + {canEditDashboard && } diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 75257fb537..75bb67a1dc 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -29,12 +29,29 @@ function updateRefreshRateOnUrl(refreshRate) { } } +function useFullscreenHandler() { + const [fullscreen, setFullscreen] = useState(has($location.search(), 'fullscreen')); + useEffect(() => { + const params = extend({}, $location.search(), { fullscreen: '1' }); + document.querySelector('body').classList.toggle('headless', fullscreen); + if (fullscreen) { + $location.search(params); + } else { + $location.search(omit(params, ['fullscreen'])); + } + }, [fullscreen]); + + const toggleFullscreen = () => setFullscreen(!fullscreen); + return [fullscreen, toggleFullscreen]; +} + function useDashboard(dashboardData) { const [dashboard, setDashboard] = useState(dashboardData); const [filters, setFilters] = useState([]); const [refreshing, setRefreshing] = useState(false); const [widgets, setWidgets] = useState(dashboard.widgets); const [editingLayout, setEditingLayout] = useState(false); + const [fullscreen, toggleFullscreen] = useFullscreenHandler(); const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]); const [refreshRate, setRefreshRate] = useState(getRefreshRateFromUrl()); const canEditDashboard = useMemo( @@ -124,6 +141,8 @@ function useDashboard(dashboardData) { setRefreshRate, editingLayout, setEditingLayout, + fullscreen, + toggleFullscreen, }; } From e3c240100724806ffc41427a8c8aeaa3b8a020de Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 8 Oct 2019 23:41:49 -0300 Subject: [PATCH 11/39] Separate refreshRateHandler hook --- client/app/pages/dashboards/useDashboard.js | 30 ++++++++++++--------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 75bb67a1dc..8d8e782c9c 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -45,6 +45,20 @@ function useFullscreenHandler() { return [fullscreen, toggleFullscreen]; } +function useRefreshRateHandler(refreshDashboard) { + const [refreshRate, setRefreshRate] = useState(getRefreshRateFromUrl()); + + useEffect(() => { + updateRefreshRateOnUrl(refreshRate); + if (refreshRate) { + const refreshTimer = setInterval(refreshDashboard, refreshRate * 1000); + return () => clearInterval(refreshTimer); + } + }, [refreshRate]); + + return [refreshRate, setRefreshRate]; +} + function useDashboard(dashboardData) { const [dashboard, setDashboard] = useState(dashboardData); const [filters, setFilters] = useState([]); @@ -53,7 +67,6 @@ function useDashboard(dashboardData) { const [editingLayout, setEditingLayout] = useState(false); const [fullscreen, toggleFullscreen] = useFullscreenHandler(); const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]); - const [refreshRate, setRefreshRate] = useState(getRefreshRateFromUrl()); const canEditDashboard = useMemo( () => has(dashboard, 'user.id') && (currentUser.id === dashboard.user.id || currentUser.hasPermission('admin')), [dashboard], @@ -109,21 +122,12 @@ function useDashboard(dashboardData) { [loadDashboard], ); - useEffect(() => { - setDashboard(dashboardData); - }, [dashboardData]); + const [refreshRate, setRefreshRate] = useRefreshRateHandler(refreshDashboard); useEffect(() => { + setDashboard(dashboardData); loadDashboard(); - }, [dashboard]); - - useEffect(() => { - updateRefreshRateOnUrl(refreshRate); - if (refreshRate) { - const refreshTimer = setInterval(refreshDashboard, refreshRate * 1000); - return () => clearInterval(refreshTimer); - } - }, [refreshRate]); + }, [dashboardData]); return { dashboard, From a1637c21cc2a585b1ff7fd9df249dd9017dd1ce9 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 8 Oct 2019 23:59:31 -0300 Subject: [PATCH 12/39] Add a few tooltips --- client/app/pages/dashboards/DashboardPage.jsx | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 094d2552d4..316d97cd2c 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -7,6 +7,7 @@ import Button from 'antd/lib/button'; import Dropdown from 'antd/lib/dropdown'; import Menu from 'antd/lib/menu'; import Icon from 'antd/lib/icon'; +import Tooltip from 'antd/lib/tooltip'; import { DashboardGrid } from '@/components/dashboards/DashboardGrid'; import { FavoritesControl } from '@/components/FavoritesControl'; import { EditInPlace } from '@/components/EditInPlace'; @@ -71,13 +72,15 @@ function RefreshButton({ dashboardOptions }) { }; return ( - + + + - - + + + + + + {canEditDashboard && } From 7b80a69b811dded197b137a3ea67e8ac9f82d508 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 9 Oct 2019 12:16:23 -0300 Subject: [PATCH 13/39] Separate DashboardControl and normalize btn width --- client/app/pages/dashboards/DashboardPage.jsx | 70 +++++++++++-------- .../app/pages/dashboards/DashboardPage.less | 31 ++++++++ .../integration/dashboard/dashboard_spec.js | 2 +- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 316d97cd2c..042be5fed3 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -99,7 +99,7 @@ function RefreshButton({ dashboardOptions }) { )} > - @@ -124,7 +124,7 @@ function DashboardMoreOptionsButton({ dashboardOptions }) { )} > - + ); } @@ -133,39 +133,49 @@ DashboardMoreOptionsButton.propTypes = { dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types }; -function DashboardHeader({ dashboardOptions }) { +function DashboardControl({ dashboardOptions }) { const { dashboard, updateDashboard, editingLayout, canEditDashboard, fullscreen, toggleFullscreen } = dashboardOptions; return ( -
- -
- {!dashboard.is_archived && ( - - {!editingLayout && ( - <> - {dashboard.is_draft && ( - - )} - - - - - - - - - {canEditDashboard && } - - - )} +
+ {!dashboard.is_archived && ( + + {!editingLayout && ( + <> + {dashboard.is_draft && ( + + )} + + + + + + + + + {canEditDashboard && } + )} -
+
+ )} +
+ ); +} + +DashboardControl.propTypes = { + dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +function DashboardHeader({ dashboardOptions }) { + return ( +
+ +
); } diff --git a/client/app/pages/dashboards/DashboardPage.less b/client/app/pages/dashboards/DashboardPage.less index 6e8b9d903e..07c3652554 100644 --- a/client/app/pages/dashboards/DashboardPage.less +++ b/client/app/pages/dashboards/DashboardPage.less @@ -31,3 +31,34 @@ } } } + +.dashboard-control { + margin: 8px 0; + + .icon-button { + width: 32px; + padding: 0 10px; + } + + .save-status { + vertical-align: middle; + margin-right: 7px; + font-size: 12px; + text-align: left; + display: inline-block; + + &[data-saving] { + opacity: 0.6; + width: 45px; + + &:after { + content: ''; + animation: saving 2s linear infinite; + } + } + + &[data-error] { + color: #F44336; + } + } +} diff --git a/client/cypress/integration/dashboard/dashboard_spec.js b/client/cypress/integration/dashboard/dashboard_spec.js index 42876c1b13..ff6214a23c 100644 --- a/client/cypress/integration/dashboard/dashboard_spec.js +++ b/client/cypress/integration/dashboard/dashboard_spec.js @@ -130,7 +130,7 @@ describe('Dashboard', () => { }); it('hides menu button', () => { - cy.get('.dashboard__control').should('exist'); + cy.get('.dashboard-control').should('exist'); cy.getByTestId('DashboardMoreMenu').should('not.be.visible'); cy.viewport(768, 800); From ab8794a977db122e782adc90423acfc2723a377e Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 9 Oct 2019 13:08:28 -0300 Subject: [PATCH 14/39] Share Button --- client/app/pages/dashboards/DashboardPage.jsx | 64 ++++++++++++------- client/app/pages/dashboards/useDashboard.js | 29 ++++++--- 2 files changed, 60 insertions(+), 33 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 042be5fed3..84a096ece9 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -26,6 +26,10 @@ function getDashboardTags() { return getTags('api/dashboards/tags').then(tags => map(tags, t => t.name)); } +function buttonType(value) { + return value ? 'primary' : 'default'; +} + function DashboardPageTitle({ dashboardOptions }) { const { dashboard, canEditDashboard, updateDashboard, editingLayout } = dashboardOptions; return ( @@ -74,7 +78,7 @@ function RefreshButton({ dashboardOptions }) { @@ -135,33 +139,45 @@ DashboardMoreOptionsButton.propTypes = { function DashboardControl({ dashboardOptions }) { const { dashboard, updateDashboard, editingLayout, - canEditDashboard, fullscreen, toggleFullscreen } = dashboardOptions; + canEditDashboard, fullscreen, toggleFullscreen, openShareDialog } = dashboardOptions; + const showPublishButton = dashboard.is_draft; + const showRefreshButton = true; + const showFullscreenButton = !dashboard.is_draft; + const showShareButton = dashboard.publicAccessEnabled || canEditDashboard && !dashboard.is_draft; + const showMoreOptionsButton = canEditDashboard; return (
- {!dashboard.is_archived && ( - - {!editingLayout && ( - <> - {dashboard.is_draft && ( - + {(!dashboard.is_archived && !editingLayout) && ( + + {showPublishButton && ( + )} - + {showRefreshButton && } - - - - - - - {canEditDashboard && } + {showFullscreenButton && ( + + + + )} + {showShareButton && ( + + + + )} + {showMoreOptionsButton && } - - )} - + )}
); diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 8d8e782c9c..01f9cb534c 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -1,9 +1,10 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; -import { isEmpty, isNaN, includes, compact, map, has, pick, keys, extend, omit } from 'lodash'; +import { isEmpty, isNaN, includes, compact, map, has, pick, keys, extend, every } from 'lodash'; import notification from '@/services/notification'; import { $location } from '@/services/ng'; import { Dashboard, collectDashboardFilters } from '@/services/dashboard'; import { currentUser } from '@/services/auth'; +import ShareDashboardDialog from './ShareDashboardDialog'; function getAffectedWidgets(widgets, updatedParameters = []) { return !isEmpty(updatedParameters) ? widgets.filter( @@ -22,11 +23,10 @@ function getRefreshRateFromUrl() { function updateRefreshRateOnUrl(refreshRate) { const params = extend({}, $location.search(), { refresh: refreshRate }); - if (refreshRate) { - $location.search(params); - } else { - $location.search(omit(params, ['refresh'])); + if (!refreshRate) { + delete params.refresh; } + $location.search(params); } function useFullscreenHandler() { @@ -34,11 +34,10 @@ function useFullscreenHandler() { useEffect(() => { const params = extend({}, $location.search(), { fullscreen: '1' }); document.querySelector('body').classList.toggle('headless', fullscreen); - if (fullscreen) { - $location.search(params); - } else { - $location.search(omit(params, ['fullscreen'])); + if (!fullscreen) { + delete params.fullscreen; } + $location.search(params); }, [fullscreen]); const toggleFullscreen = () => setFullscreen(!fullscreen); @@ -71,6 +70,17 @@ function useDashboard(dashboardData) { () => has(dashboard, 'user.id') && (currentUser.id === dashboard.user.id || currentUser.hasPermission('admin')), [dashboard], ); + const hasOnlySafeQueries = useMemo( + () => every(widgets, w => (w.getQuery() ? w.getQuery().is_safe : true)), + [widgets], + ); + + const openShareDialog = useCallback(() => { + ShareDashboardDialog.showModal({ + dashboard, + hasOnlySafeQueries, + }).result.finally(() => setDashboard(extend({}, dashboard))); + }); const updateDashboard = useCallback((data) => { setDashboard(extend({}, dashboard, data)); @@ -147,6 +157,7 @@ function useDashboard(dashboardData) { setEditingLayout, fullscreen, toggleFullscreen, + openShareDialog, }; } From 2ee69f7f3d1ffcd7f3fc138867d2e3c3984bc1ea Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 9 Oct 2019 14:09:53 -0300 Subject: [PATCH 15/39] Fix serach params not updating --- client/app/pages/dashboards/useDashboard.js | 31 ++++++++------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 01f9cb534c..e9f53ec17e 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { isEmpty, isNaN, includes, compact, map, has, pick, keys, extend, every } from 'lodash'; import notification from '@/services/notification'; -import { $location } from '@/services/ng'; +import { $location, $rootScope } from '@/services/ng'; import { Dashboard, collectDashboardFilters } from '@/services/dashboard'; import { currentUser } from '@/services/auth'; import ShareDashboardDialog from './ShareDashboardDialog'; @@ -21,34 +21,22 @@ function getRefreshRateFromUrl() { return isNaN(refreshRate) ? null : Math.max(30, refreshRate); } -function updateRefreshRateOnUrl(refreshRate) { - const params = extend({}, $location.search(), { refresh: refreshRate }); - if (!refreshRate) { - delete params.refresh; - } - $location.search(params); -} - -function useFullscreenHandler() { +function useFullscreenHandler(updateUrlSearch) { const [fullscreen, setFullscreen] = useState(has($location.search(), 'fullscreen')); useEffect(() => { - const params = extend({}, $location.search(), { fullscreen: '1' }); document.querySelector('body').classList.toggle('headless', fullscreen); - if (!fullscreen) { - delete params.fullscreen; - } - $location.search(params); + updateUrlSearch('fullscreen', fullscreen ? true : null); }, [fullscreen]); const toggleFullscreen = () => setFullscreen(!fullscreen); return [fullscreen, toggleFullscreen]; } -function useRefreshRateHandler(refreshDashboard) { +function useRefreshRateHandler(refreshDashboard, updateUrlSearch) { const [refreshRate, setRefreshRate] = useState(getRefreshRateFromUrl()); useEffect(() => { - updateRefreshRateOnUrl(refreshRate); + updateUrlSearch('refresh', refreshRate || null); if (refreshRate) { const refreshTimer = setInterval(refreshDashboard, refreshRate * 1000); return () => clearInterval(refreshTimer); @@ -64,7 +52,6 @@ function useDashboard(dashboardData) { const [refreshing, setRefreshing] = useState(false); const [widgets, setWidgets] = useState(dashboard.widgets); const [editingLayout, setEditingLayout] = useState(false); - const [fullscreen, toggleFullscreen] = useFullscreenHandler(); const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]); const canEditDashboard = useMemo( () => has(dashboard, 'user.id') && (currentUser.id === dashboard.user.id || currentUser.hasPermission('admin')), @@ -132,7 +119,13 @@ function useDashboard(dashboardData) { [loadDashboard], ); - const [refreshRate, setRefreshRate] = useRefreshRateHandler(refreshDashboard); + const updateUrlSearch = useCallback((...params) => { + $location.search(...params); + $rootScope.$applyAsync(); + }, []); + + const [refreshRate, setRefreshRate] = useRefreshRateHandler(refreshDashboard, updateUrlSearch); + const [fullscreen, toggleFullscreen] = useFullscreenHandler(updateUrlSearch); useEffect(() => { setDashboard(dashboardData); From a95d866ee787bb2c699b664362fdc915754311b3 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 9 Oct 2019 14:10:27 -0300 Subject: [PATCH 16/39] Enumerate More Options --- client/app/pages/dashboards/DashboardPage.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 84a096ece9..78f838c84a 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -124,7 +124,10 @@ function DashboardMoreOptionsButton({ dashboardOptions }) { placement="bottomRight" overlay={( - {!dashboard.is_archived && Edit} + Edit + Manage Permissions + {!dashboard.is_draft && Unpublish} + Archive )} > From a024b245d28c336aa8600f0433aab2ed2831f738 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 9 Oct 2019 20:00:29 -0300 Subject: [PATCH 17/39] Toggle Publish options --- client/app/pages/dashboards/DashboardPage.jsx | 14 ++++++++----- client/app/pages/dashboards/useDashboard.js | 21 +++++++++++++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 78f838c84a..06e207646e 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -13,6 +13,7 @@ import { FavoritesControl } from '@/components/FavoritesControl'; import { EditInPlace } from '@/components/EditInPlace'; import { DashboardTagsControl } from '@/components/tags-control/TagsControl'; import { Dashboard } from '@/services/dashboard'; +import recordEvent from '@/services/recordEvent'; import { $route } from '@/services/ng'; import getTags from '@/services/getTags'; import { clientConfig } from '@/services/auth'; @@ -117,7 +118,7 @@ RefreshButton.propTypes = { }; function DashboardMoreOptionsButton({ dashboardOptions }) { - const { dashboard } = dashboardOptions; + const { dashboard, togglePublished } = dashboardOptions; return ( Edit Manage Permissions - {!dashboard.is_draft && Unpublish} + {!dashboard.is_draft &&
Unpublish} Archive )} @@ -141,7 +142,7 @@ DashboardMoreOptionsButton.propTypes = { }; function DashboardControl({ dashboardOptions }) { - const { dashboard, updateDashboard, editingLayout, + const { dashboard, editingLayout, togglePublished, canEditDashboard, fullscreen, toggleFullscreen, openShareDialog } = dashboardOptions; const showPublishButton = dashboard.is_draft; const showRefreshButton = true; @@ -153,7 +154,7 @@ function DashboardControl({ dashboardOptions }) { {(!dashboard.is_archived && !editingLayout) && ( {showPublishButton && ( - )} @@ -240,7 +241,10 @@ function DashboardPage() { useEffect(() => { Dashboard.get({ slug: $route.current.params.dashboardSlug }).$promise - .then(setDashboard) + .then((dashboardData) => { + recordEvent('view', 'dashboard', dashboardData.id); + setDashboard(dashboardData); + }) .catch((error) => { throw new PromiseRejectionError(error); }); }, []); diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index e9f53ec17e..b84738ec85 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -4,6 +4,7 @@ import notification from '@/services/notification'; import { $location, $rootScope } from '@/services/ng'; import { Dashboard, collectDashboardFilters } from '@/services/dashboard'; import { currentUser } from '@/services/auth'; +import recordEvent from '@/services/recordEvent'; import ShareDashboardDialog from './ShareDashboardDialog'; function getAffectedWidgets(widgets, updatedParameters = []) { @@ -69,11 +70,14 @@ function useDashboard(dashboardData) { }).result.finally(() => setDashboard(extend({}, dashboard))); }); - const updateDashboard = useCallback((data) => { + const updateDashboard = useCallback((data, includeVersion = true) => { setDashboard(extend({}, dashboard, data)); // for some reason the request uses the id as slug - data = { ...data, slug: dashboard.id, version: dashboard.version }; - Dashboard.save( + data = { ...data, slug: dashboard.id }; + if (includeVersion) { + data = { ...data, version: dashboard.version }; + } + return Dashboard.save( data, updatedDashboard => setDashboard(extend({}, dashboard, pick(updatedDashboard, keys(data)))), (error) => { @@ -87,9 +91,17 @@ function useDashboard(dashboardData) { ); } }, - ); + ).$promise; }, [dashboard]); + const togglePublished = useCallback( + () => { + recordEvent('toggle_published', 'dashboard', dashboard.id); + updateDashboard({ is_draft: !dashboard.is_draft }, false); + }, + [dashboard, updateDashboard], + ); + const loadWidget = useCallback((widget, forceRefresh = false) => { widget.getParametersDefs(); // Force widget to read parameters values from URL setWidgets([...dashboard.widgets]); @@ -141,6 +153,7 @@ function useDashboard(dashboardData) { setFilters, refreshDashboard, updateDashboard, + togglePublished, loadWidget, refreshWidget, canEditDashboard, From fed179d59e15e1306265190d7dafd66e9f7ebab2 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 10 Oct 2019 00:19:39 -0300 Subject: [PATCH 18/39] Archive Dashboard --- client/app/pages/dashboards/DashboardPage.jsx | 18 ++++++++++++++++-- client/app/pages/dashboards/useDashboard.js | 6 ++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 06e207646e..ea1718989e 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -7,6 +7,7 @@ import Button from 'antd/lib/button'; import Dropdown from 'antd/lib/dropdown'; import Menu from 'antd/lib/menu'; import Icon from 'antd/lib/icon'; +import Modal from 'antd/lib/modal'; import Tooltip from 'antd/lib/tooltip'; import { DashboardGrid } from '@/components/dashboards/DashboardGrid'; import { FavoritesControl } from '@/components/FavoritesControl'; @@ -118,7 +119,20 @@ RefreshButton.propTypes = { }; function DashboardMoreOptionsButton({ dashboardOptions }) { - const { dashboard, togglePublished } = dashboardOptions; + const { dashboard, togglePublished, archiveDashboard } = dashboardOptions; + + const archive = () => { + Modal.confirm({ + title: 'Archive Dashboard', + content: `Are you sure you want to archive the "${dashboard.name}" dashboard?`, + okText: 'Archive', + okType: 'danger', + onOk: archiveDashboard, + maskClosable: true, + autoFocusButton: null, + }); + }; + return ( Edit Manage Permissions {!dashboard.is_draft && Unpublish} - Archive + Archive )} > diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index b84738ec85..1cddfd65eb 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -131,6 +131,11 @@ function useDashboard(dashboardData) { [loadDashboard], ); + const archiveDashboard = useCallback(() => { + recordEvent('archive', 'dashboard', dashboard.id); + dashboard.$delete().then(() => loadDashboard()); + }, [dashboard, updateDashboard]); + const updateUrlSearch = useCallback((...params) => { $location.search(...params); $rootScope.$applyAsync(); @@ -154,6 +159,7 @@ function useDashboard(dashboardData) { refreshDashboard, updateDashboard, togglePublished, + archiveDashboard, loadWidget, refreshWidget, canEditDashboard, From 50f99c63b2f1031774fa4ac136c95bf5409d023d Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 10 Oct 2019 00:38:06 -0300 Subject: [PATCH 19/39] Parameters + Filters --- client/app/pages/dashboards/DashboardPage.jsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index ea1718989e..0420be698c 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; -import { map } from 'lodash'; +import { map, isEmpty } from 'lodash'; import { react2angular } from 'react2angular'; import Button from 'antd/lib/button'; import Dropdown from 'antd/lib/dropdown'; @@ -13,6 +13,8 @@ import { DashboardGrid } from '@/components/dashboards/DashboardGrid'; import { FavoritesControl } from '@/components/FavoritesControl'; import { EditInPlace } from '@/components/EditInPlace'; import { DashboardTagsControl } from '@/components/tags-control/TagsControl'; +import { Parameters } from '@/components/Parameters'; +import { Filters } from '@/components/Filters'; import { Dashboard } from '@/services/dashboard'; import recordEvent from '@/services/recordEvent'; import { $route } from '@/services/ng'; @@ -220,14 +222,24 @@ DashboardHeader.propTypes = { function DashboardComponent(props) { const dashboardOptions = useDashboard(props.dashboard); - const { dashboard, widgets, filters, loadWidget, - refreshWidget, editingLayout } = dashboardOptions; + const { dashboard, widgets, filters, setFilters, loadWidget, + globalParameters, refreshDashboard, refreshWidget, editingLayout } = dashboardOptions; return ( <> + {!isEmpty(globalParameters) && ( +
+ +
+ )} + {!isEmpty(filters) && ( +
+ +
+ )}
Date: Sat, 26 Oct 2019 14:50:49 -0300 Subject: [PATCH 20/39] Prepare Manage Permissions --- client/app/pages/dashboards/DashboardPage.jsx | 6 ++++-- client/app/pages/dashboards/useDashboard.js | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 0420be698c..508c7653d8 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -121,7 +121,7 @@ RefreshButton.propTypes = { }; function DashboardMoreOptionsButton({ dashboardOptions }) { - const { dashboard, togglePublished, archiveDashboard } = dashboardOptions; + const { dashboard, togglePublished, archiveDashboard, managePermissions } = dashboardOptions; const archive = () => { Modal.confirm({ @@ -142,7 +142,9 @@ function DashboardMoreOptionsButton({ dashboardOptions }) { overlay={( Edit - Manage Permissions + {clientConfig.showPermissionsControl && ( + Manage Permissions + )} {!dashboard.is_draft && Unpublish} Archive diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 1cddfd65eb..c867d1bb7f 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -68,7 +68,11 @@ function useDashboard(dashboardData) { dashboard, hasOnlySafeQueries, }).result.finally(() => setDashboard(extend({}, dashboard))); - }); + }, [dashboard, hasOnlySafeQueries]); + + const managePermissions = useCallback(() => { + // TODO: open PermissionsEditorDialog + }, []); const updateDashboard = useCallback((data, includeVersion = true) => { setDashboard(extend({}, dashboard, data)); @@ -170,6 +174,7 @@ function useDashboard(dashboardData) { fullscreen, toggleFullscreen, openShareDialog, + managePermissions, }; } From 4540d776e0892b85cc94f0a3e19788d47a9c075e Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Sat, 26 Oct 2019 19:28:48 -0300 Subject: [PATCH 21/39] Start to create edit mode --- client/app/pages/dashboards/DashboardPage.jsx | 77 ++++++++++++++++-- client/app/pages/dashboards/dashboard.less | 1 + client/app/pages/dashboards/useDashboard.js | 78 ++++++++++++++----- 3 files changed, 131 insertions(+), 25 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 508c7653d8..fa1fe6bb61 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -4,6 +4,7 @@ import cx from 'classnames'; import { map, isEmpty } from 'lodash'; import { react2angular } from 'react2angular'; import Button from 'antd/lib/button'; +import Checkbox from 'antd/lib/checkbox'; import Dropdown from 'antd/lib/dropdown'; import Menu from 'antd/lib/menu'; import Icon from 'antd/lib/icon'; @@ -121,7 +122,7 @@ RefreshButton.propTypes = { }; function DashboardMoreOptionsButton({ dashboardOptions }) { - const { dashboard, togglePublished, archiveDashboard, managePermissions } = dashboardOptions; + const { dashboard, setEditingLayout, togglePublished, archiveDashboard, managePermissions } = dashboardOptions; const archive = () => { Modal.confirm({ @@ -141,7 +142,7 @@ function DashboardMoreOptionsButton({ dashboardOptions }) { placement="bottomRight" overlay={( - Edit + setEditingLayout(true)}>Edit {clientConfig.showPermissionsControl && ( Manage Permissions )} @@ -160,8 +161,8 @@ DashboardMoreOptionsButton.propTypes = { }; function DashboardControl({ dashboardOptions }) { - const { dashboard, editingLayout, togglePublished, - canEditDashboard, fullscreen, toggleFullscreen, openShareDialog } = dashboardOptions; + const { dashboard, togglePublished, canEditDashboard, + fullscreen, toggleFullscreen, showShareDashboardDialog } = dashboardOptions; const showPublishButton = dashboard.is_draft; const showRefreshButton = true; const showFullscreenButton = !dashboard.is_draft; @@ -169,7 +170,7 @@ function DashboardControl({ dashboardOptions }) { const showMoreOptionsButton = canEditDashboard; return (
- {(!dashboard.is_archived && !editingLayout) && ( + {(!dashboard.is_archived) && ( {showPublishButton && ( +
+ ); +} + +DashboardEditControl.propTypes = { + dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +function DashboardSettings({ dashboardOptions }) { + const { dashboard, updateDashboard, loadDashboard } = dashboardOptions; + return ( +
+ updateDashboard({ dashboard_filters_enabled: target.checked }) + .then(() => loadDashboard())} + > + Use Dashboard Level Filters + +
+ ); +} + +DashboardSettings.propTypes = { + dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + +function AddWidgetContainer({ dashboardOptions }) { + const { showAddTextboxDialog, showAddWidgetDialog } = dashboardOptions; + return ( +
+

+ + + Widgets are individual query visualizations or text boxes you can place + on your dashboard in various arrangements. + +

+
+ + +
+
+ ); +} + +AddWidgetContainer.propTypes = { + dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types +}; + function DashboardHeader({ dashboardOptions }) { + const { editingLayout } = dashboardOptions; + const DashboardControlComponent = editingLayout ? DashboardEditControl : DashboardControl; + return (
- +
); } @@ -242,6 +303,7 @@ function DashboardComponent(props) {
)} + {editingLayout && }
+ {editingLayout && } ); } diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less index 68ba5b3736..0c8472383a 100644 --- a/client/app/pages/dashboards/dashboard.less +++ b/client/app/pages/dashboards/dashboard.less @@ -144,6 +144,7 @@ dashboard-grid { } span { + vertical-align: middle; padding-left: 30px; } } diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index c867d1bb7f..76509896d7 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -5,8 +5,19 @@ import { $location, $rootScope } from '@/services/ng'; import { Dashboard, collectDashboardFilters } from '@/services/dashboard'; import { currentUser } from '@/services/auth'; import recordEvent from '@/services/recordEvent'; +import AddWidgetDialog from '@/components/dashboards/AddWidgetDialog'; +import TextboxDialog from '@/components/dashboards/TextboxDialog'; +import { + editableMappingsToParameterMappings, + synchronizeWidgetTitles, +} from '@/components/ParameterMappingInput'; import ShareDashboardDialog from './ShareDashboardDialog'; +function updateUrlSearch(...params) { + $location.search(...params); + $rootScope.$applyAsync(); +} + function getAffectedWidgets(widgets, updatedParameters = []) { return !isEmpty(updatedParameters) ? widgets.filter( widget => Object.values(widget.getParameterMappings()).filter( @@ -22,7 +33,7 @@ function getRefreshRateFromUrl() { return isNaN(refreshRate) ? null : Math.max(30, refreshRate); } -function useFullscreenHandler(updateUrlSearch) { +function useFullscreenHandler() { const [fullscreen, setFullscreen] = useState(has($location.search(), 'fullscreen')); useEffect(() => { document.querySelector('body').classList.toggle('headless', fullscreen); @@ -33,7 +44,7 @@ function useFullscreenHandler(updateUrlSearch) { return [fullscreen, toggleFullscreen]; } -function useRefreshRateHandler(refreshDashboard, updateUrlSearch) { +function useRefreshRateHandler(refreshDashboard) { const [refreshRate, setRefreshRate] = useState(getRefreshRateFromUrl()); useEffect(() => { @@ -47,15 +58,26 @@ function useRefreshRateHandler(refreshDashboard, updateUrlSearch) { return [refreshRate, setRefreshRate]; } +function useEditModeHandler(canEditDashboard) { + const [editingLayout, setEditingLayout] = useState(canEditDashboard && has($location.search(), 'edit')); + + useEffect(() => { + updateUrlSearch('edit', editingLayout ? true : null); + }, [editingLayout]); + + return [editingLayout, editing => setEditingLayout(canEditDashboard && editing)]; +} + function useDashboard(dashboardData) { const [dashboard, setDashboard] = useState(dashboardData); const [filters, setFilters] = useState([]); const [refreshing, setRefreshing] = useState(false); const [widgets, setWidgets] = useState(dashboard.widgets); - const [editingLayout, setEditingLayout] = useState(false); const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]); const canEditDashboard = useMemo( - () => has(dashboard, 'user.id') && (currentUser.id === dashboard.user.id || currentUser.hasPermission('admin')), + () => !dashboard.is_archived && has(dashboard, 'user.id') && ( + currentUser.id === dashboard.user.id || currentUser.hasPermission('admin') + ), [dashboard], ); const hasOnlySafeQueries = useMemo( @@ -63,13 +85,6 @@ function useDashboard(dashboardData) { [widgets], ); - const openShareDialog = useCallback(() => { - ShareDashboardDialog.showModal({ - dashboard, - hasOnlySafeQueries, - }).result.finally(() => setDashboard(extend({}, dashboard))); - }, [dashboard, hasOnlySafeQueries]); - const managePermissions = useCallback(() => { // TODO: open PermissionsEditorDialog }, []); @@ -140,13 +155,38 @@ function useDashboard(dashboardData) { dashboard.$delete().then(() => loadDashboard()); }, [dashboard, updateDashboard]); - const updateUrlSearch = useCallback((...params) => { - $location.search(...params); - $rootScope.$applyAsync(); - }, []); + const showShareDashboardDialog = useCallback(() => { + ShareDashboardDialog.showModal({ + dashboard, + hasOnlySafeQueries, + }).result.finally(() => setDashboard(extend({}, dashboard))); + }, [dashboard, hasOnlySafeQueries]); + + const showAddTextboxDialog = useCallback(() => { + TextboxDialog.showModal({ + dashboard, + onConfirm: text => dashboard.addWidget(text).then(() => setWidgets(dashboard.widgets)), + }); + }, [dashboard]); + + const showAddWidgetDialog = useCallback(() => { + AddWidgetDialog.showModal({ + dashboard, + onConfirm: (visualization, parameterMappings) => dashboard.addWidget(visualization, { + parameterMappings: editableMappingsToParameterMappings(parameterMappings), + }).then((widget) => { + const widgetsToSave = [ + widget, + ...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets), + ]; + return Promise.all(widgetsToSave.map(w => w.save())).then(() => setWidgets(dashboard.widgets)); + }), + }); + }, [dashboard]); - const [refreshRate, setRefreshRate] = useRefreshRateHandler(refreshDashboard, updateUrlSearch); - const [fullscreen, toggleFullscreen] = useFullscreenHandler(updateUrlSearch); + const [refreshRate, setRefreshRate] = useRefreshRateHandler(refreshDashboard); + const [fullscreen, toggleFullscreen] = useFullscreenHandler(); + const [editingLayout, setEditingLayout] = useEditModeHandler(canEditDashboard); useEffect(() => { setDashboard(dashboardData); @@ -173,7 +213,9 @@ function useDashboard(dashboardData) { setEditingLayout, fullscreen, toggleFullscreen, - openShareDialog, + showShareDashboardDialog, + showAddTextboxDialog, + showAddWidgetDialog, managePermissions, }; } From 20d5aec755eb67c4687f06dc1864aa2fdcd58cd0 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Mon, 28 Oct 2019 20:21:25 -0300 Subject: [PATCH 22/39] Add Edit Mode functionalities --- client/app/pages/dashboards/DashboardPage.jsx | 41 ++++-- client/app/pages/dashboards/useDashboard.js | 127 +++++++++++++++--- 2 files changed, 138 insertions(+), 30 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index fa1fe6bb61..c6fcc8bfab 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -23,7 +23,7 @@ import getTags from '@/services/getTags'; import { clientConfig } from '@/services/auth'; import { durationHumanize } from '@/filters'; import PromiseRejectionError from '@/lib/promise-rejection-error'; -import useDashboard from './useDashboard'; +import useDashboard, { DashboardStatusEnum } from './useDashboard'; import './DashboardPage.less'; @@ -211,12 +211,27 @@ DashboardControl.propTypes = { }; function DashboardEditControl({ dashboardOptions }) { - const { setEditingLayout } = dashboardOptions; + const { setEditingLayout, doneBtnClickedWhileSaving, dashboardStatus, retrySaveDashboardLayout } = dashboardOptions; + let status; + if (dashboardStatus === DashboardStatusEnum.SAVED) { + status = (Saved); + } else if (dashboardStatus === DashboardStatusEnum.SAVING) { + status = (Saving); + } else { + status = ( + Saving Failed + ); + } return (
- + {status} + {dashboardStatus === DashboardStatusEnum.SAVING_FAILED ? ( + + ) : ( + + )}
); } @@ -285,8 +300,8 @@ DashboardHeader.propTypes = { function DashboardComponent(props) { const dashboardOptions = useDashboard(props.dashboard); - const { dashboard, widgets, filters, setFilters, loadWidget, - globalParameters, refreshDashboard, refreshWidget, editingLayout } = dashboardOptions; + const { dashboard, filters, setFilters, loadDashboard, loadWidget, removeWidget, saveDashboardLayout, + globalParameters, refreshDashboard, refreshWidget, editingLayout, setGridDisabled } = dashboardOptions; return ( <> @@ -307,15 +322,15 @@ function DashboardComponent(props) {
{editingLayout && } diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 76509896d7..af1e06d363 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; -import { isEmpty, isNaN, includes, compact, map, has, pick, keys, extend, every } from 'lodash'; +import { isEmpty, isNaN, includes, compact, map, has, pick, keys, + extend, every, find, debounce, isMatch, pickBy } from 'lodash'; import notification from '@/services/notification'; import { $location, $rootScope } from '@/services/ng'; import { Dashboard, collectDashboardFilters } from '@/services/dashboard'; @@ -13,6 +14,12 @@ import { } from '@/components/ParameterMappingInput'; import ShareDashboardDialog from './ShareDashboardDialog'; +export const DashboardStatusEnum = { + SAVED: 'saved', + SAVING: 'saving', + SAVING_FAILED: 'saving_failed', +}; + function updateUrlSearch(...params) { $location.search(...params); $rootScope.$applyAsync(); @@ -28,6 +35,14 @@ function getAffectedWidgets(widgets, updatedParameters = []) { ) : widgets; } +function getChangedPositions(widgets, nextPositions = {}) { + return pickBy(nextPositions, (nextPos, widgetId) => { + const widget = find(widgets, { id: Number(widgetId) }); + const prevPos = widget.options.position; + return !isMatch(prevPos, nextPos); + }); +} + function getRefreshRateFromUrl() { const refreshRate = parseFloat($location.search().refresh); return isNaN(refreshRate) ? null : Math.max(30, refreshRate); @@ -58,21 +73,91 @@ function useRefreshRateHandler(refreshDashboard) { return [refreshRate, setRefreshRate]; } -function useEditModeHandler(canEditDashboard) { +function useEditModeHandler(canEditDashboard, widgets) { const [editingLayout, setEditingLayout] = useState(canEditDashboard && has($location.search(), 'edit')); + const [dashboardStatus, setDashboardStatus] = useState(DashboardStatusEnum.SAVED); + const [recentPositions, setRecentPositions] = useState([]); + const [doneBtnClickedWhileSaving, setDoneBtnClickedWhileSaving] = useState(false); useEffect(() => { updateUrlSearch('edit', editingLayout ? true : null); }, [editingLayout]); - return [editingLayout, editing => setEditingLayout(canEditDashboard && editing)]; + useEffect(() => { + if (!canEditDashboard && editingLayout) { + setEditingLayout(false); + } + }, [canEditDashboard, editingLayout]); + + useEffect(() => { + if (doneBtnClickedWhileSaving && dashboardStatus === DashboardStatusEnum.SAVED) { + setDoneBtnClickedWhileSaving(false); + setEditingLayout(false); + } + }, [doneBtnClickedWhileSaving, dashboardStatus]); + + const saveDashboardLayout = useCallback((positions) => { + if (!canEditDashboard) { + return; + } + + const changedPositions = getChangedPositions(widgets, positions); + + setDashboardStatus(DashboardStatusEnum.SAVING); + setRecentPositions(positions); + const saveChangedWidgets = map(changedPositions, (position, id) => { + // find widget + const widget = find(widgets, { id: Number(id) }); + + // skip already deleted widget + if (!widget) { + return Promise.resolve(); + } + + return widget.save('options', { position }); + }); + + return Promise.all(saveChangedWidgets) + .then(() => setDashboardStatus(DashboardStatusEnum.SAVED)) + .catch(() => { + setDashboardStatus(DashboardStatusEnum.SAVING_FAILED); + notification.error('Error saving changes.'); + }); + }, [canEditDashboard, widgets]); + + const saveDashboardLayoutDebounced = useCallback((...args) => { + setDashboardStatus(DashboardStatusEnum.SAVING); + return debounce(() => saveDashboardLayout(...args), 2000)(); + }, [saveDashboardLayout]); + + const retrySaveDashboardLayout = useCallback( + () => saveDashboardLayout(recentPositions), + [recentPositions, saveDashboardLayout], + ); + + const setEditing = useCallback((editing) => { + if (!editing && dashboardStatus !== DashboardStatusEnum.SAVED) { + setDoneBtnClickedWhileSaving(true); + return; + } + setEditingLayout(canEditDashboard && editing); + }, [dashboardStatus, canEditDashboard]); + + return { + editingLayout, + setEditingLayout: setEditing, + saveDashboardLayout: editingLayout ? saveDashboardLayoutDebounced : saveDashboardLayout, + retrySaveDashboardLayout, + doneBtnClickedWhileSaving, + dashboardStatus, + }; } function useDashboard(dashboardData) { const [dashboard, setDashboard] = useState(dashboardData); const [filters, setFilters] = useState([]); const [refreshing, setRefreshing] = useState(false); - const [widgets, setWidgets] = useState(dashboard.widgets); + const [gridDisabled, setGridDisabled] = useState(false); const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]); const canEditDashboard = useMemo( () => !dashboard.is_archived && has(dashboard, 'user.id') && ( @@ -81,8 +166,8 @@ function useDashboard(dashboardData) { [dashboard], ); const hasOnlySafeQueries = useMemo( - () => every(widgets, w => (w.getQuery() ? w.getQuery().is_safe : true)), - [widgets], + () => every(dashboard.widgets, w => (w.getQuery() ? w.getQuery().is_safe : true)), + [dashboard], ); const managePermissions = useCallback(() => { @@ -123,24 +208,29 @@ function useDashboard(dashboardData) { const loadWidget = useCallback((widget, forceRefresh = false) => { widget.getParametersDefs(); // Force widget to read parameters values from URL - setWidgets([...dashboard.widgets]); - return widget.load(forceRefresh).finally(() => setWidgets([...dashboard.widgets])); + setDashboard(extend({}, dashboard)); + return widget.load(forceRefresh).finally(() => setDashboard(extend({}, dashboard))); }, [dashboard]); const refreshWidget = useCallback(widget => loadWidget(widget, true), [loadWidget]); + const removeWidget = useCallback((widgetId) => { + dashboard.widgets = dashboard.widgets.filter(widget => widget.id !== undefined && widget.id !== widgetId); + setDashboard(extend({}, dashboard)); + }, [dashboard]); + const loadDashboard = useCallback((forceRefresh = false, updatedParameters = []) => { - const affectedWidgets = getAffectedWidgets(widgets, updatedParameters); + const affectedWidgets = getAffectedWidgets(dashboard.widgets, updatedParameters); const loadWidgetPromises = compact( affectedWidgets.map(widget => loadWidget(widget, forceRefresh).catch(error => error)), ); return Promise.all(loadWidgetPromises).then(() => { - const queryResults = compact(map(widgets, widget => widget.getQueryResult())); + const queryResults = compact(map(dashboard.widgets, widget => widget.getQueryResult())); const updatedFilters = collectDashboardFilters(dashboard, queryResults, $location.search()); setFilters(updatedFilters); }); - }, [dashboard, widgets, loadWidget]); + }, [dashboard, loadWidget]); const refreshDashboard = useCallback( (updatedParameters) => { @@ -165,7 +255,7 @@ function useDashboard(dashboardData) { const showAddTextboxDialog = useCallback(() => { TextboxDialog.showModal({ dashboard, - onConfirm: text => dashboard.addWidget(text).then(() => setWidgets(dashboard.widgets)), + onConfirm: text => dashboard.addWidget(text).then(() => setDashboard(extend({}, dashboard))), }); }, [dashboard]); @@ -179,14 +269,15 @@ function useDashboard(dashboardData) { widget, ...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets), ]; - return Promise.all(widgetsToSave.map(w => w.save())).then(() => setWidgets(dashboard.widgets)); + return Promise.all(widgetsToSave.map(w => w.save())) + .then(() => setDashboard(extend({}, dashboard))); }), }); }, [dashboard]); const [refreshRate, setRefreshRate] = useRefreshRateHandler(refreshDashboard); const [fullscreen, toggleFullscreen] = useFullscreenHandler(); - const [editingLayout, setEditingLayout] = useEditModeHandler(canEditDashboard); + const editModeHandler = useEditModeHandler(!gridDisabled && canEditDashboard, dashboard.widgets); useEffect(() => { setDashboard(dashboardData); @@ -195,22 +286,24 @@ function useDashboard(dashboardData) { return { dashboard, - widgets, globalParameters, refreshing, filters, setFilters, + loadDashboard, refreshDashboard, updateDashboard, togglePublished, archiveDashboard, loadWidget, refreshWidget, + removeWidget, canEditDashboard, refreshRate, setRefreshRate, - editingLayout, - setEditingLayout, + ...editModeHandler, + gridDisabled, + setGridDisabled, fullscreen, toggleFullscreen, showShareDashboardDialog, From d0191d412e252b04f0a44946f879c392839c0e0a Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Mon, 28 Oct 2019 21:36:34 -0300 Subject: [PATCH 23/39] Use previous state when updating dashboard --- client/app/pages/dashboards/useDashboard.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index af1e06d363..2c44e16514 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -175,7 +175,7 @@ function useDashboard(dashboardData) { }, []); const updateDashboard = useCallback((data, includeVersion = true) => { - setDashboard(extend({}, dashboard, data)); + setDashboard(currentDashboard => extend({}, currentDashboard, data)); // for some reason the request uses the id as slug data = { ...data, slug: dashboard.id }; if (includeVersion) { @@ -183,7 +183,9 @@ function useDashboard(dashboardData) { } return Dashboard.save( data, - updatedDashboard => setDashboard(extend({}, dashboard, pick(updatedDashboard, keys(data)))), + updatedDashboard => setDashboard(currentDashboard => extend({}, + currentDashboard, + pick(updatedDashboard, keys(data)))), (error) => { if (error.status === 403) { notification.error('Dashboard update failed', 'Permission Denied.'); @@ -208,15 +210,16 @@ function useDashboard(dashboardData) { const loadWidget = useCallback((widget, forceRefresh = false) => { widget.getParametersDefs(); // Force widget to read parameters values from URL - setDashboard(extend({}, dashboard)); - return widget.load(forceRefresh).finally(() => setDashboard(extend({}, dashboard))); + setDashboard(currentDashboard => extend({}, currentDashboard)); + return widget.load(forceRefresh) + .finally(() => setDashboard(currentDashboard => extend({}, currentDashboard))); }, [dashboard]); const refreshWidget = useCallback(widget => loadWidget(widget, true), [loadWidget]); const removeWidget = useCallback((widgetId) => { dashboard.widgets = dashboard.widgets.filter(widget => widget.id !== undefined && widget.id !== widgetId); - setDashboard(extend({}, dashboard)); + setDashboard(currentDashboard => extend({}, currentDashboard)); }, [dashboard]); const loadDashboard = useCallback((forceRefresh = false, updatedParameters = []) => { @@ -249,13 +252,14 @@ function useDashboard(dashboardData) { ShareDashboardDialog.showModal({ dashboard, hasOnlySafeQueries, - }).result.finally(() => setDashboard(extend({}, dashboard))); + }).result.finally(() => setDashboard(currentDashboard => extend({}, currentDashboard))); }, [dashboard, hasOnlySafeQueries]); const showAddTextboxDialog = useCallback(() => { TextboxDialog.showModal({ dashboard, - onConfirm: text => dashboard.addWidget(text).then(() => setDashboard(extend({}, dashboard))), + onConfirm: text => dashboard.addWidget(text) + .then(() => setDashboard(currentDashboard => extend({}, currentDashboard))), }); }, [dashboard]); @@ -270,7 +274,7 @@ function useDashboard(dashboardData) { ...synchronizeWidgetTitles(widget.options.parameterMappings, dashboard.widgets), ]; return Promise.all(widgetsToSave.map(w => w.save())) - .then(() => setDashboard(extend({}, dashboard))); + .then(() => setDashboard(currentDashboard => extend({}, currentDashboard))); }), }); }, [dashboard]); From 7504b5277e5cb47dfe0f18a7cc6a01b9df0713fa Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 29 Oct 2019 12:39:28 -0300 Subject: [PATCH 24/39] Mobile adjustments --- client/app/assets/less/inc/base.less | 17 ++--------------- client/app/pages/dashboards/DashboardPage.less | 5 ----- client/app/pages/dashboards/dashboard.less | 13 +++++++------ 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less index 505a913171..c4c33916b2 100755 --- a/client/app/assets/less/inc/base.less +++ b/client/app/assets/less/inc/base.less @@ -247,23 +247,10 @@ text.slicetext { display: inline-block; } - favorites-control { + .favorites-control { + font-size: 19px; margin-right: 5px; } - - @media (max-width: 767px) { - display: block; - - favorites-control { - float: left; - } - - h3 { - width: 100%; - margin-bottom: 5px !important; - display: block !important; - } - } } .page-header-wrapper, .page-header--new { diff --git a/client/app/pages/dashboards/DashboardPage.less b/client/app/pages/dashboards/DashboardPage.less index 07c3652554..0e7ba6f7c5 100644 --- a/client/app/pages/dashboards/DashboardPage.less +++ b/client/app/pages/dashboards/DashboardPage.less @@ -8,11 +8,6 @@ font-weight: 500; } - .favorites-control { - font-size: 19px; - margin-right: 5px; - } - .profile-image { width: 16px; height: 16px; diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less index 0c8472383a..330910e969 100644 --- a/client/app/pages/dashboards/dashboard.less +++ b/client/app/pages/dashboards/dashboard.less @@ -1,4 +1,5 @@ @import '../../assets/less/inc/variables'; +@import '../../components/app-header/AppHeader.less'; .profile__image_thumb--dashboard { width: 16px; @@ -14,6 +15,12 @@ z-index: 99; width: 100%; top: 0; + + @media @mobileBreakpoint { + & { + position: static; + } + } } .dashboard-header { @@ -74,12 +81,6 @@ .dashboard-header { padding: 0 !important; - .page-title h3 { - margin-bottom: 0 !important; - font-size: 18px; - line-height: 2; - } - .dashboard__control { margin: 5px 0; } From 0daf14b29f446ef0b9e18de55532c90246b0c6b9 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 29 Oct 2019 13:58:14 -0300 Subject: [PATCH 25/39] PermissionsEditorDialog + Dashboard page title --- client/app/pages/dashboards/useDashboard.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 2c44e16514..5a0bb46a09 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -8,6 +8,7 @@ import { currentUser } from '@/services/auth'; import recordEvent from '@/services/recordEvent'; import AddWidgetDialog from '@/components/dashboards/AddWidgetDialog'; import TextboxDialog from '@/components/dashboards/TextboxDialog'; +import PermissionsEditorDialog from '@/components/permissions-editor/PermissionsEditorDialog'; import { editableMappingsToParameterMappings, synchronizeWidgetTitles, @@ -171,8 +172,13 @@ function useDashboard(dashboardData) { ); const managePermissions = useCallback(() => { - // TODO: open PermissionsEditorDialog - }, []); + const aclUrl = `api/dashboards/${dashboard.id}/acl`; + PermissionsEditorDialog.showModal({ + aclUrl, + context: 'dashboard', + author: dashboard.user, + }); + }, [dashboard]); const updateDashboard = useCallback((data, includeVersion = true) => { setDashboard(currentDashboard => extend({}, currentDashboard, data)); @@ -285,6 +291,7 @@ function useDashboard(dashboardData) { useEffect(() => { setDashboard(dashboardData); + document.title = dashboardData.name; loadDashboard(); }, [dashboardData]); From c65448d36cc3bab39e0c8006afefa8388a796528 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 29 Oct 2019 14:43:16 -0300 Subject: [PATCH 26/39] Update Dashboard spec --- client/app/pages/dashboards/DashboardPage.jsx | 15 ++++--- .../integration/dashboard/dashboard_spec.js | 40 +++++++++---------- client/cypress/support/dashboard/index.js | 13 +++--- 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index c6fcc8bfab..60b98811a7 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -122,7 +122,8 @@ RefreshButton.propTypes = { }; function DashboardMoreOptionsButton({ dashboardOptions }) { - const { dashboard, setEditingLayout, togglePublished, archiveDashboard, managePermissions } = dashboardOptions; + const { dashboard, setEditingLayout, togglePublished, + archiveDashboard, managePermissions, gridDisabled } = dashboardOptions; const archive = () => { Modal.confirm({ @@ -141,8 +142,10 @@ function DashboardMoreOptionsButton({ dashboardOptions }) { trigger={['click']} placement="bottomRight" overlay={( - - setEditingLayout(true)}>Edit + + + setEditingLayout(true)}>Edit + {clientConfig.showPermissionsControl && ( Manage Permissions )} @@ -151,7 +154,9 @@ function DashboardMoreOptionsButton({ dashboardOptions }) { )} > - + ); } @@ -309,7 +314,7 @@ function DashboardComponent(props) { dashboardOptions={dashboardOptions} /> {!isEmpty(globalParameters) && ( -
+
)} diff --git a/client/cypress/integration/dashboard/dashboard_spec.js b/client/cypress/integration/dashboard/dashboard_spec.js index bdb77a8d59..24e048b560 100644 --- a/client/cypress/integration/dashboard/dashboard_spec.js +++ b/client/cypress/integration/dashboard/dashboard_spec.js @@ -39,17 +39,14 @@ describe('Dashboard', () => { createDashboard('Foo Bar').then(({ slug }) => { cy.visit(`/dashboard/${slug}`); - cy.getByTestId('DashboardMoreMenu') - .click() - .within(() => { - cy.get('li') - .contains('Archive') - .click(); - }); + cy.getByTestId('DashboardMoreButton') + .click(); - cy.get('.btn-warning') + cy.getByTestId('DashboardMoreButtonMenu') .contains('Archive') .click(); + + cy.get('.ant-modal .ant-btn').contains('Archive').click({ force: true }); cy.get('.label-tag-archived').should('exist'); cy.visit('/dashboards'); @@ -91,28 +88,29 @@ describe('Dashboard', () => { }); it('hides edit option', () => { - cy.getByTestId('DashboardMoreMenu') + cy.getByTestId('DashboardMoreButton') .click() - .should('be.visible') - .within(() => { - cy.get('li') - .contains('Edit') - .as('editButton') - .should('not.be.visible'); - }); + .should('be.visible'); + + cy.getByTestId('DashboardMoreButtonMenu') + .contains('Edit') + .as('editButton') + .should('not.be.visible'); cy.viewport(801, 800); cy.get('@editButton').should('be.visible'); }); it('disables edit mode', function () { + cy.viewport(801, 800); cy.visit(this.dashboardEditUrl); cy.contains('button', 'Done Editing') .as('saveButton') - .should('be.disabled'); + .should('exist'); - cy.viewport(801, 800); - cy.get('@saveButton').should('not.be.disabled'); + cy.viewport(800, 800); + cy.contains('button', 'Done Editing') + .should('not.exist'); }); }); @@ -131,10 +129,10 @@ describe('Dashboard', () => { it('hides menu button', () => { cy.get('.dashboard-control').should('exist'); - cy.getByTestId('DashboardMoreMenu').should('not.be.visible'); + cy.getByTestId('DashboardMoreButton').should('not.be.visible'); cy.viewport(768, 800); - cy.getByTestId('DashboardMoreMenu').should('be.visible'); + cy.getByTestId('DashboardMoreButton').should('be.visible'); }); }); }); diff --git a/client/cypress/support/dashboard/index.js b/client/cypress/support/dashboard/index.js index cd7b38a0c1..015f200250 100644 --- a/client/cypress/support/dashboard/index.js +++ b/client/cypress/support/dashboard/index.js @@ -20,13 +20,12 @@ export function createQueryAndAddWidget(dashboardId, queryData = {}, widgetOptio } export function editDashboard() { - cy.getByTestId('DashboardMoreMenu') - .click() - .within(() => { - cy.get('li') - .contains('Edit') - .click(); - }); + cy.getByTestId('DashboardMoreButton') + .click(); + + cy.getByTestId('DashboardMoreButtonMenu') + .contains('Edit') + .click(); } export function shareDashboard() { From 3fb95da840daafc67f87e051e2a582114de52fdc Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 29 Oct 2019 19:45:22 -0300 Subject: [PATCH 27/39] Fix other specs --- client/app/pages/dashboards/DashboardPage.jsx | 8 ++++++-- client/app/pages/dashboards/PublicDashboardPage.jsx | 6 +++--- client/cypress/integration/dashboard/sharing_spec.js | 5 ++--- client/cypress/integration/dashboard/textbox_spec.js | 4 ++-- client/cypress/integration/dashboard/widget_spec.js | 2 +- 5 files changed, 14 insertions(+), 11 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 60b98811a7..26d21873df 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -276,8 +276,12 @@ function AddWidgetContainer({ dashboardOptions }) {
- - + +
); diff --git a/client/app/pages/dashboards/PublicDashboardPage.jsx b/client/app/pages/dashboards/PublicDashboardPage.jsx index 64e1b867c3..36c473a33c 100644 --- a/client/app/pages/dashboards/PublicDashboardPage.jsx +++ b/client/app/pages/dashboards/PublicDashboardPage.jsx @@ -17,8 +17,8 @@ import './PublicDashboardPage.less'; function PublicDashboard({ dashboard }) { - const { globalParameters, filters, setFilters, refreshDashboard, - widgets, loadWidget, refreshWidget } = useDashboard(dashboard); + const { globalParameters, filters, setFilters, + refreshDashboard, loadWidget, refreshWidget } = useDashboard(dashboard); return (
@@ -36,7 +36,7 @@ function PublicDashboard({ dashboard }) {
{ createQuery({ options }).then(({ id: queryId }) => { cy.visit(dashboardUrl); editDashboard(); - cy.contains('a', 'Add Widget').click(); + cy.getByTestId('AddWidgetButton').click(); cy.getByTestId('AddWidgetDialog').within(() => { cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click(); }); @@ -110,7 +110,6 @@ describe('Dashboard Sharing', () => { // then, after it is shared, add an unsafe parameterized query to it const secondWidgetPos = { autoHeight: false, col: 3, sizeY: 6 }; createQueryAndAddWidget(this.dashboardId, unsafeQueryData, { position: secondWidgetPos }).then(() => { - cy.visit(this.dashboardUrl); cy.logout(); cy.visit(secretAddress); cy.getByTestId('TableVisualization', { timeout: 10000 }).should('exist'); @@ -134,7 +133,7 @@ describe('Dashboard Sharing', () => { createQuery({ options }).then(({ id: queryId }) => { cy.visit(dashboardUrl); editDashboard(); - cy.contains('a', 'Add Widget').click(); + cy.getByTestId('AddWidgetButton').click(); cy.getByTestId('AddWidgetDialog').within(() => { cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click(); }); diff --git a/client/cypress/integration/dashboard/textbox_spec.js b/client/cypress/integration/dashboard/textbox_spec.js index 610ac97d54..8d78697315 100644 --- a/client/cypress/integration/dashboard/textbox_spec.js +++ b/client/cypress/integration/dashboard/textbox_spec.js @@ -19,7 +19,7 @@ describe('Textbox', () => { it('adds textbox', function () { cy.visit(this.dashboardUrl); editDashboard(); - cy.contains('a', 'Add Textbox').click(); + cy.getByTestId('AddTextboxButton').click(); cy.getByTestId('TextboxDialog').within(() => { cy.get('textarea').type('Hello World!'); }); @@ -138,7 +138,7 @@ describe('Textbox', () => { }) .should(($el) => { const { top, left } = $el.offset(); - expect(top).to.eq(214); + expect(top).to.eq(218); expect(left).to.eq(215); expect($el.width()).to.eq(585); expect($el.height()).to.eq(185); diff --git a/client/cypress/integration/dashboard/widget_spec.js b/client/cypress/integration/dashboard/widget_spec.js index e726e7c3cd..db14cdd421 100644 --- a/client/cypress/integration/dashboard/widget_spec.js +++ b/client/cypress/integration/dashboard/widget_spec.js @@ -20,7 +20,7 @@ describe('Widget', () => { createQuery().then(({ id: queryId }) => { cy.visit(this.dashboardUrl); editDashboard(); - cy.contains('a', 'Add Widget').click(); + cy.getByTestId('AddWidgetButton').click(); cy.getByTestId('AddWidgetDialog').within(() => { cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click(); }); From a03a6aa1f269808d4bd0215a255b067be99257bd Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 29 Oct 2019 21:26:21 -0300 Subject: [PATCH 28/39] Break dashboard.less --- .../app/pages/dashboards/DashboardPage.less | 84 ++++++++++ client/app/pages/dashboards/dashboard.less | 156 ------------------ 2 files changed, 84 insertions(+), 156 deletions(-) delete mode 100644 client/app/pages/dashboards/dashboard.less diff --git a/client/app/pages/dashboards/DashboardPage.less b/client/app/pages/dashboards/DashboardPage.less index 0e7ba6f7c5..8dcbc4fcfb 100644 --- a/client/app/pages/dashboards/DashboardPage.less +++ b/client/app/pages/dashboards/DashboardPage.less @@ -1,6 +1,40 @@ +@import '../../assets/less/inc/variables'; +@import '../../components/app-header/AppHeader.less'; + +/**** + grid bg - based on 6 cols, 35px rows and 15px spacing +****/ + +// let the bg go all the way to the bottom +dashboard-page, dashboard-page .container { + display: flex; + flex-grow: 1; + flex-direction: column; + width: 100%; +} + +#dashboard-container { + position: relative; + flex-grow: 1; + display: flex; +} + .dashboard-header { padding: 0 15px !important; margin: 0 0 10px !important; + position: -webkit-sticky; // required for Safari + position: sticky; + background: #f6f7f9; + z-index: 99; + width: 100%; + top: 0; + + @media @mobileBreakpoint { + & { + padding: 0 !important; + position: static; + } + } h3 { margin: 0.2em 0; @@ -57,3 +91,53 @@ } } } + +@keyframes saving { + 0%, 100% { + content: '.'; + } + 33% { + content: '..'; + } + 66% { + content: '...'; + } +} + +.add-widget-container { + background: #fff; + border-radius: @redash-radius; + padding: 15px; + position: fixed; + left: 15px; + bottom: 20px; + width: calc(~'100% - 30px'); + z-index: 99; + box-shadow: fade(@redash-gray, 50%) 0px 7px 29px -3px; + display: flex; + justify-content: space-between; + + h2 { + margin: 0; + font-size: 14px; + line-height: 2.1; + font-weight: 400; + + .zmdi { + margin: 0; + margin-right: 5px; + font-size: 24px; + position: absolute; + bottom: 18px; + } + + span { + vertical-align: middle; + padding-left: 30px; + } + } + + .btn { + align-self: center; + } +} diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less deleted file mode 100644 index 330910e969..0000000000 --- a/client/app/pages/dashboards/dashboard.less +++ /dev/null @@ -1,156 +0,0 @@ -@import '../../assets/less/inc/variables'; -@import '../../components/app-header/AppHeader.less'; - -.profile__image_thumb--dashboard { - width: 16px; - height: 16px; - border-radius: 100%; - margin: 3px 5px 0 0; -} - -.dashboard-header { - position: -webkit-sticky; // required for Safari - position: sticky; - background: #f6f7f9; - z-index: 99; - width: 100%; - top: 0; - - @media @mobileBreakpoint { - & { - position: static; - } - } -} - -.dashboard-header { - .tags-control a { - opacity: 0; - transition: opacity 0.2s ease-in-out; - } - - &:hover { - .tags-control a { - opacity: 1; - } - } -} - -.dashboard__control { - margin: 8px 0; - - .save-status { - vertical-align: middle; - margin-right: 7px; - font-size: 12px; - text-align: left; - display: inline-block; - - &[data-saving] { - opacity: 0.6; - width: 45px; - - &:after { - content: ''; - animation: saving 2s linear infinite; - } - } - - &[data-error] { - color: #F44336; - } - } -} - -@keyframes saving { - 0%, 100% { - content: '.'; - } - 33% { - content: '..'; - } - 66% { - content: '...'; - } -} - - -// Mobile fixes -@media (max-width: 767px) { - dashboard-page { - .dashboard-header { - padding: 0 !important; - - .dashboard__control { - margin: 5px 0; - } - } - - favorites-control { - margin-top: 4px; - } - } -} - -/**** - grid bg - based on 6 cols, 35px rows and 15px spacing -****/ - -// let the bg go all the way to the bottom -dashboard-page, dashboard-page .container { - display: flex; - flex-grow: 1; - flex-direction: column; - width: 100%; -} - -#dashboard-container { - position: relative; - flex-grow: 1; - display: flex; -} - -// soon deprecated -dashboard-grid { - flex-grow: 1; - display: flex; - flex-direction: column; -} - -.add-widget-container { - background: #fff; - border-radius: @redash-radius; - padding: 15px; - position: fixed; - left: 15px; - bottom: 20px; - width: calc(~'100% - 30px'); - z-index: 99; - box-shadow: fade(@redash-gray, 50%) 0px 7px 29px -3px; - display: flex; - justify-content: space-between; - - h2 { - margin: 0; - font-size: 14px; - line-height: 2.1; - font-weight: 400; - - .zmdi { - margin: 0; - margin-right: 5px; - font-size: 24px; - position: absolute; - bottom: 18px; - } - - span { - vertical-align: middle; - padding-left: 30px; - } - } - - .btn { - align-self: center; - } -} From feb9a631d953e5851106024ecf458d91668a5b90 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 29 Oct 2019 21:26:50 -0300 Subject: [PATCH 29/39] Hide publish button on mobile --- client/app/pages/dashboards/DashboardPage.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 26d21873df..b6e19aacdb 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -178,7 +178,7 @@ function DashboardControl({ dashboardOptions }) { {(!dashboard.is_archived) && ( {showPublishButton && ( - )} From 67c5eae72dc89ee512b1c3dda58f9cc221194a5c Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 29 Oct 2019 21:41:05 -0300 Subject: [PATCH 30/39] Angular Cleaning --- client/app/components/BigMessage.jsx | 9 +- client/app/components/Filters.jsx | 9 +- client/app/components/HelpTrigger.jsx | 2 +- .../app/components/NoTaggedObjectsFound.jsx | 2 +- client/app/components/SelectItemsDialog.jsx | 2 +- .../components/dashboards/DashboardGrid.jsx | 9 +- .../items-list/components/EmptyState.jsx | 2 +- .../items-list/components/LoadingState.jsx | 2 +- .../components/tags-control/TagsControl.jsx | 1 - .../dashboards/DashboardListEmptyState.jsx | 2 +- client/app/pages/dashboards/DashboardPage.jsx | 4 +- .../pages/dashboards/PublicDashboardPage.jsx | 6 +- client/app/pages/dashboards/dashboard.html | 130 ------ client/app/pages/dashboards/dashboard.js | 434 ------------------ .../queries-list/QueriesListEmptyState.jsx | 2 +- .../EditVisualizationDialog.jsx | 2 +- .../visualizations/VisualizationRenderer.jsx | 2 +- 17 files changed, 20 insertions(+), 600 deletions(-) delete mode 100644 client/app/pages/dashboards/dashboard.html delete mode 100644 client/app/pages/dashboards/dashboard.js diff --git a/client/app/components/BigMessage.jsx b/client/app/components/BigMessage.jsx index 1063c1c950..e87f1e9dd9 100644 --- a/client/app/components/BigMessage.jsx +++ b/client/app/components/BigMessage.jsx @@ -1,8 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { react2angular } from 'react2angular'; -export function BigMessage({ message, icon, children, className }) { +function BigMessage({ message, icon, children, className }) { return (

@@ -28,8 +27,4 @@ BigMessage.defaultProps = { className: 'tiled bg-white', }; -export default function init(ngModule) { - ngModule.component('bigMessage', react2angular(BigMessage)); -} - -init.init = true; +export default BigMessage; diff --git a/client/app/components/Filters.jsx b/client/app/components/Filters.jsx index 0c6307ea72..bde070df33 100644 --- a/client/app/components/Filters.jsx +++ b/client/app/components/Filters.jsx @@ -2,7 +2,6 @@ import { isArray, indexOf, get, map, includes, every, some, toNumber } from 'lod import moment from 'moment'; import React from 'react'; import PropTypes from 'prop-types'; -import { react2angular } from 'react2angular'; import Select from 'antd/lib/select'; import { formatColumnValue } from '@/filters'; @@ -71,7 +70,7 @@ export function filterData(rows, filters = []) { return result; } -export function Filters({ filters, onChange }) { +function Filters({ filters, onChange }) { if (filters.length === 0) { return null; } @@ -133,8 +132,4 @@ Filters.defaultProps = { onChange: () => {}, }; -export default function init(ngModule) { - ngModule.component('filters', react2angular(Filters)); -} - -init.init = true; +export default Filters; diff --git a/client/app/components/HelpTrigger.jsx b/client/app/components/HelpTrigger.jsx index 68fc112abd..4268af7df6 100644 --- a/client/app/components/HelpTrigger.jsx +++ b/client/app/components/HelpTrigger.jsx @@ -4,7 +4,7 @@ import cx from 'classnames'; import Tooltip from 'antd/lib/tooltip'; import Drawer from 'antd/lib/drawer'; import Icon from 'antd/lib/icon'; -import { BigMessage } from '@/components/BigMessage'; +import BigMessage from '@/components/BigMessage'; import DynamicComponent from '@/components/DynamicComponent'; import './HelpTrigger.less'; diff --git a/client/app/components/NoTaggedObjectsFound.jsx b/client/app/components/NoTaggedObjectsFound.jsx index 38ae3840fe..0880dad30c 100644 --- a/client/app/components/NoTaggedObjectsFound.jsx +++ b/client/app/components/NoTaggedObjectsFound.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; -import { BigMessage } from '@/components/BigMessage'; +import BigMessage from '@/components/BigMessage'; import { TagsControl } from '@/components/tags-control/TagsControl'; export function NoTaggedObjectsFound({ objectType, tags }) { diff --git a/client/app/components/SelectItemsDialog.jsx b/client/app/components/SelectItemsDialog.jsx index f54dc27773..92e7ae8e3e 100644 --- a/client/app/components/SelectItemsDialog.jsx +++ b/client/app/components/SelectItemsDialog.jsx @@ -7,7 +7,7 @@ import Input from 'antd/lib/input'; import List from 'antd/lib/list'; import Button from 'antd/lib/button'; import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; -import { BigMessage } from '@/components/BigMessage'; +import BigMessage from '@/components/BigMessage'; import LoadingState from '@/components/items-list/components/LoadingState'; import notification from '@/services/notification'; diff --git a/client/app/components/dashboards/DashboardGrid.jsx b/client/app/components/dashboards/DashboardGrid.jsx index 47b319dd8d..401ad1f8a6 100644 --- a/client/app/components/dashboards/DashboardGrid.jsx +++ b/client/app/components/dashboards/DashboardGrid.jsx @@ -1,7 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import { chain, cloneDeep, find } from 'lodash'; -import { react2angular } from 'react2angular'; import cx from 'classnames'; import { Responsive, WidthProvider } from 'react-grid-layout'; import { VisualizationWidget, TextboxWidget, RestrictedWidget } from '@/components/dashboards/dashboard-widget'; @@ -34,7 +33,7 @@ const WidgetType = PropTypes.shape({ const SINGLE = 'single-column'; const MULTI = 'multi-column'; -export class DashboardGrid extends React.Component { +class DashboardGrid extends React.Component { static propTypes = { isEditing: PropTypes.bool.isRequired, isPublic: PropTypes.bool, @@ -231,8 +230,4 @@ export class DashboardGrid extends React.Component { } } -export default function init(ngModule) { - ngModule.component('dashboardGrid', react2angular(DashboardGrid)); -} - -init.init = true; +export default DashboardGrid; diff --git a/client/app/components/items-list/components/EmptyState.jsx b/client/app/components/items-list/components/EmptyState.jsx index e2e58ffc45..14ccab2c3f 100644 --- a/client/app/components/items-list/components/EmptyState.jsx +++ b/client/app/components/items-list/components/EmptyState.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { BigMessage } from '@/components/BigMessage'; +import BigMessage from '@/components/BigMessage'; // Default "list empty" message for list pages export default function EmptyState(props) { diff --git a/client/app/components/items-list/components/LoadingState.jsx b/client/app/components/items-list/components/LoadingState.jsx index 9db93bd578..3740c45570 100644 --- a/client/app/components/items-list/components/LoadingState.jsx +++ b/client/app/components/items-list/components/LoadingState.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { BigMessage } from '@/components/BigMessage'; +import BigMessage from '@/components/BigMessage'; // Default "loading" message for list pages export default function LoadingState(props) { diff --git a/client/app/components/tags-control/TagsControl.jsx b/client/app/components/tags-control/TagsControl.jsx index 71e0825991..cbcc4fcbb2 100644 --- a/client/app/components/tags-control/TagsControl.jsx +++ b/client/app/components/tags-control/TagsControl.jsx @@ -94,7 +94,6 @@ export const DashboardTagsControl = modelTagsControl({ export default function init(ngModule) { ngModule.component('queryTagsControl', react2angular(QueryTagsControl)); - ngModule.component('dashboardTagsControl', react2angular(DashboardTagsControl)); } init.init = true; diff --git a/client/app/pages/dashboards/DashboardListEmptyState.jsx b/client/app/pages/dashboards/DashboardListEmptyState.jsx index 810335ffe5..7c618d4040 100644 --- a/client/app/pages/dashboards/DashboardListEmptyState.jsx +++ b/client/app/pages/dashboards/DashboardListEmptyState.jsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { BigMessage } from '@/components/BigMessage'; +import BigMessage from '@/components/BigMessage'; import { NoTaggedObjectsFound } from '@/components/NoTaggedObjectsFound'; import { EmptyState } from '@/components/empty-state/EmptyState'; diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index b6e19aacdb..5f35688e28 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -10,12 +10,12 @@ import Menu from 'antd/lib/menu'; import Icon from 'antd/lib/icon'; import Modal from 'antd/lib/modal'; import Tooltip from 'antd/lib/tooltip'; -import { DashboardGrid } from '@/components/dashboards/DashboardGrid'; +import DashboardGrid from '@/components/dashboards/DashboardGrid'; import { FavoritesControl } from '@/components/FavoritesControl'; import { EditInPlace } from '@/components/EditInPlace'; import { DashboardTagsControl } from '@/components/tags-control/TagsControl'; import { Parameters } from '@/components/Parameters'; -import { Filters } from '@/components/Filters'; +import Filters from '@/components/Filters'; import { Dashboard } from '@/services/dashboard'; import recordEvent from '@/services/recordEvent'; import { $route } from '@/services/ng'; diff --git a/client/app/pages/dashboards/PublicDashboardPage.jsx b/client/app/pages/dashboards/PublicDashboardPage.jsx index 36c473a33c..5aeab42580 100644 --- a/client/app/pages/dashboards/PublicDashboardPage.jsx +++ b/client/app/pages/dashboards/PublicDashboardPage.jsx @@ -2,11 +2,11 @@ import React from 'react'; import { isEmpty } from 'lodash'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; -import { BigMessage } from '@/components/BigMessage'; +import BigMessage from '@/components/BigMessage'; import { PageHeader } from '@/components/PageHeader'; import { Parameters } from '@/components/Parameters'; -import { DashboardGrid } from '@/components/dashboards/DashboardGrid'; -import { Filters } from '@/components/Filters'; +import DashboardGrid from '@/components/dashboards/DashboardGrid'; +import Filters from '@/components/Filters'; import { Dashboard } from '@/services/dashboard'; import { $route as ngRoute } from '@/services/ng'; import PromiseRejectionError from '@/lib/promise-rejection-error'; diff --git a/client/app/pages/dashboards/dashboard.html b/client/app/pages/dashboards/dashboard.html deleted file mode 100644 index 114a07fd63..0000000000 --- a/client/app/pages/dashboards/dashboard.html +++ /dev/null @@ -1,130 +0,0 @@ -
-
-
- -

- -

- - {{$ctrl.dashboard.user.name}} - - - -
-
- -
- - - Saving - - - - Saving Failed - - - - - Saved - - -
- - - -
- - - -
- - -
- - -
-
- -
- -
- -
- -
- -
- -
- -
- -
- -
-

- - -

- -
-
diff --git a/client/app/pages/dashboards/dashboard.js b/client/app/pages/dashboards/dashboard.js deleted file mode 100644 index 2b6ad11b82..0000000000 --- a/client/app/pages/dashboards/dashboard.js +++ /dev/null @@ -1,434 +0,0 @@ -import * as _ from 'lodash'; -import PromiseRejectionError from '@/lib/promise-rejection-error'; -import getTags from '@/services/getTags'; -import { policy } from '@/services/policy'; -import { - editableMappingsToParameterMappings, - synchronizeWidgetTitles, -} from '@/components/ParameterMappingInput'; -import { collectDashboardFilters } from '@/services/dashboard'; -import { durationHumanize } from '@/filters'; -import template from './dashboard.html'; -import ShareDashboardDialog from './ShareDashboardDialog'; -import AddWidgetDialog from '@/components/dashboards/AddWidgetDialog'; -import TextboxDialog from '@/components/dashboards/TextboxDialog'; -import PermissionsEditorDialog from '@/components/permissions-editor/PermissionsEditorDialog'; -import notification from '@/services/notification'; - -import './dashboard.less'; - -function getChangedPositions(widgets, nextPositions = {}) { - return _.pickBy(nextPositions, (nextPos, widgetId) => { - const widget = _.find(widgets, { id: Number(widgetId) }); - const prevPos = widget.options.position; - return !_.isMatch(prevPos, nextPos); - }); -} - -function DashboardCtrl( - $routeParams, - $location, - $timeout, - $q, - $uibModal, - $scope, - Title, - AlertDialog, - Dashboard, - currentUser, - clientConfig, - Events, -) { - let recentPositions = []; - - const saveDashboardLayout = (changedPositions) => { - if (!this.dashboard.canEdit()) { - return; - } - - this.saveInProgress = true; - - const saveChangedWidgets = _.map(changedPositions, (position, id) => { - // find widget - const widget = _.find(this.dashboard.widgets, { id: Number(id) }); - - // skip already deleted widget - if (!widget) { - return Promise.resolve(); - } - - return widget.save('options', { position }); - }); - - return $q - .all(saveChangedWidgets) - .then(() => { - this.isLayoutDirty = false; - if (this.editBtnClickedWhileSaving) { - this.layoutEditing = false; - } - }) - .catch(() => { - notification.error('Error saving changes.'); - }) - .finally(() => { - this.saveInProgress = false; - this.editBtnClickedWhileSaving = false; - $scope.$applyAsync(); - }); - }; - - const saveDashboardLayoutDebounced = (...args) => { - this.saveDelay = true; - return _.debounce(() => { - this.saveDelay = false; - saveDashboardLayout(...args); - }, 2000)(); - }; - - this.retrySaveDashboardLayout = () => { - this.onLayoutChange(recentPositions); - }; - - // grid vars - this.saveDelay = false; - this.saveInProgress = false; - this.recentLayoutPositions = {}; - this.editBtnClickedWhileSaving = false; - this.layoutEditing = false; - this.isLayoutDirty = false; - this.isGridDisabled = false; - - // dashboard vars - this.isFullscreen = false; - this.refreshRate = null; - this.showPermissionsControl = clientConfig.showPermissionsControl; - this.globalParameters = []; - this.isDashboardOwner = false; - this.filters = []; - - this.refreshRates = clientConfig.dashboardRefreshIntervals.map(interval => ({ - name: durationHumanize(interval), - rate: interval, - enabled: true, - })); - - const allowedIntervals = policy.getDashboardRefreshIntervals(); - if (_.isArray(allowedIntervals)) { - _.each(this.refreshRates, (rate) => { - rate.enabled = allowedIntervals.indexOf(rate.rate) >= 0; - }); - } - - this.setRefreshRate = (rate, load = true) => { - this.refreshRate = rate; - if (rate !== null) { - if (load) { - this.refreshDashboard(); - } - this.autoRefresh(); - } - }; - - this.extractGlobalParameters = () => { - this.globalParameters = this.dashboard.getParametersDefs(); - }; - - // 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( - widget => Object.values(widget.getParameterMappings()).filter( - ({ type }) => type === 'dashboard-level', - ).some( - ({ mapTo }) => _.includes(updatedParameters.map(p => p.name), mapTo), - ), - ) : this.dashboard.widgets; - - const queryResultPromises = _.compact(affectedWidgets.map(widget => this.loadWidget(widget, forceRefresh))); - - return $q.all(queryResultPromises).then((queryResults) => { - this.filters = collectDashboardFilters(dashboard, queryResults, $location.search()); - this.filtersOnChange = (allFilters) => { - this.filters = allFilters; - $scope.$applyAsync(); - }; - }); - }; - - const renderDashboard = (dashboard, force) => { - Title.set(dashboard.name); - this.extractGlobalParameters(); - collectFilters(dashboard, force); - }; - - this.loadDashboard = _.throttle((force) => { - Dashboard.get( - { slug: $routeParams.dashboardSlug }, - (dashboard) => { - this.dashboard = dashboard; - this.isDashboardOwner = currentUser.id === dashboard.user.id || currentUser.hasPermission('admin'); - Events.record('view', 'dashboard', dashboard.id); - renderDashboard(dashboard, force); - - if ($location.search().edit === true) { - $location.search('edit', null); - this.editLayout(true); - } - - if ($location.search().refresh !== undefined) { - if (this.refreshRate === null) { - const refreshRate = Math.max(30, parseFloat($location.search().refresh)); - - this.setRefreshRate( - { - name: durationHumanize(refreshRate), - rate: refreshRate, - }, - false, - ); - } - } - }, - (rejection) => { - const statusGroup = Math.floor(rejection.status / 100); - if (statusGroup === 5) { - // recoverable errors - all 5** (server is temporarily unavailable - // for some reason, but it should get up soon). - this.loadDashboard(); - } else { - // all kind of 4** errors are not recoverable, so just display them - throw new PromiseRejectionError(rejection); - } - }, - ); - }, 1000); - - this.loadDashboard(); - - this.refreshDashboard = (parameters) => { - this.refreshInProgress = true; - collectFilters(this.dashboard, true, parameters).finally(() => { - this.refreshInProgress = false; - }); - }; - - this.autoRefresh = () => { - $timeout(() => { - this.refreshDashboard(); - }, this.refreshRate.rate * 1000).then(() => this.autoRefresh()); - }; - - this.archiveDashboard = () => { - const archive = () => { - Events.record('archive', 'dashboard', this.dashboard.id); - // this API call will not modify widgets, but will reload them, so they will - // loose their internal state. So we'll save widgets before doing API call and - // restore them after. - const widgets = this.dashboard.widgets; - this.dashboard.$delete().then(() => { - this.dashboard.widgets = widgets; - }); - }; - - const title = 'Archive Dashboard'; - const message = `Are you sure you want to archive the "${this.dashboard.name}" dashboard?`; - const confirm = { class: 'btn-warning', title: 'Archive' }; - - AlertDialog.open(title, message, confirm).then(archive); - }; - - this.showManagePermissionsModal = () => { - const aclUrl = `api/dashboards/${this.dashboard.id}/acl`; - PermissionsEditorDialog.showModal({ - aclUrl, - context: 'dashboard', - author: this.dashboard.user, - }); - }; - - this.onLayoutChange = (positions) => { - recentPositions = positions; // required for retry if subsequent save fails - - // determine position changes - const changedPositions = getChangedPositions(this.dashboard.widgets, positions); - if (_.isEmpty(changedPositions)) { - this.isLayoutDirty = false; - $scope.$applyAsync(); - return; - } - - this.isLayoutDirty = true; - $scope.$applyAsync(); - - // debounce in edit mode, immediate in preview - if (this.layoutEditing) { - saveDashboardLayoutDebounced(changedPositions); - } else { - saveDashboardLayout(changedPositions); - } - }; - - this.onBreakpointChanged = (isSingleCol) => { - this.isGridDisabled = isSingleCol; - $scope.$applyAsync(); - }; - - this.editLayout = (isEditing) => { - this.layoutEditing = isEditing; - }; - - this.loadTags = () => getTags('api/dashboards/tags').then(tags => _.map(tags, t => t.name)); - - const updateDashboard = (data) => { - _.extend(this.dashboard, data); - data = _.extend({}, data, { - slug: this.dashboard.id, - version: this.dashboard.version, - }); - Dashboard.save( - data, - (dashboard) => { - _.extend(this.dashboard, _.pick(dashboard, _.keys(data))); - }, - (error) => { - if (error.status === 403) { - notification.error('Dashboard update failed', 'Permission Denied.'); - } else if (error.status === 409) { - notification.error( - 'It seems like the dashboard has been modified by another user. ', - 'Please copy/backup your changes and reload this page.', - { duration: null }, - ); - } - }, - ); - }; - - this.saveName = (name) => { - updateDashboard({ name }); - }; - - this.saveTags = (tags) => { - updateDashboard({ tags }); - }; - - this.updateDashboardFiltersState = () => { - collectFilters(this.dashboard, false); - updateDashboard({ - dashboard_filters_enabled: this.dashboard.dashboard_filters_enabled, - }); - }; - - this.showAddTextboxDialog = () => { - TextboxDialog.showModal({ - dashboard: this.dashboard, - onConfirm: text => this.dashboard.addWidget(text).then(this.onWidgetAdded), - }); - }; - - this.showAddWidgetDialog = () => { - AddWidgetDialog.showModal({ - dashboard: this.dashboard, - onConfirm: (visualization, parameterMappings) => this.dashboard.addWidget(visualization, { - parameterMappings: editableMappingsToParameterMappings(parameterMappings), - }).then((widget) => { - const widgetsToSave = [ - widget, - ...synchronizeWidgetTitles(widget.options.parameterMappings, this.dashboard.widgets), - ]; - return Promise.all(widgetsToSave.map(w => w.save())).then(this.onWidgetAdded); - }), - }); - }; - - this.onWidgetAdded = () => { - this.extractGlobalParameters(); - collectFilters(this.dashboard, false); - // Save position of newly added widget (but not entire layout) - const widget = _.last(this.dashboard.widgets); - if (_.isObject(widget)) { - return widget.save(); - } - $scope.$applyAsync(); - }; - - this.removeWidget = (widgetId) => { - this.dashboard.widgets = this.dashboard.widgets.filter(w => w.id !== undefined && w.id !== widgetId); - this.extractGlobalParameters(); - collectFilters(this.dashboard, false); - $scope.$applyAsync(); - }; - - this.toggleFullscreen = () => { - this.isFullscreen = !this.isFullscreen; - document.querySelector('body').classList.toggle('headless'); - - if (this.isFullscreen) { - $location.search('fullscreen', true); - } else { - $location.search('fullscreen', null); - } - }; - - this.togglePublished = () => { - Events.record('toggle_published', 'dashboard', this.dashboard.id); - this.dashboard.is_draft = !this.dashboard.is_draft; - this.saveInProgress = true; - Dashboard.save( - { - slug: this.dashboard.id, - name: this.dashboard.name, - is_draft: this.dashboard.is_draft, - }, - (dashboard) => { - this.saveInProgress = false; - this.dashboard.version = dashboard.version; - }, - ); - }; - - if (_.has($location.search(), 'fullscreen')) { - this.toggleFullscreen(); - } - - this.openShareForm = () => { - const hasOnlySafeQueries = _.every( - this.dashboard.widgets, - w => (w.getQuery() ? w.getQuery().is_safe : true), - ); - - ShareDashboardDialog.showModal({ - dashboard: this.dashboard, - hasOnlySafeQueries, - }); - }; -} - -export default function init(ngModule) { - ngModule.component('dashboardPageOld', { - template, - controller: DashboardCtrl, - }); - - return { - '/dashboardold/:dashboardSlug': { - template: '', - reloadOnSearch: false, - }, - }; -} - -init.init = true; diff --git a/client/app/pages/queries-list/QueriesListEmptyState.jsx b/client/app/pages/queries-list/QueriesListEmptyState.jsx index e499de7c9b..39fa1e36b0 100644 --- a/client/app/pages/queries-list/QueriesListEmptyState.jsx +++ b/client/app/pages/queries-list/QueriesListEmptyState.jsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { BigMessage } from '@/components/BigMessage'; +import BigMessage from '@/components/BigMessage'; import { NoTaggedObjectsFound } from '@/components/NoTaggedObjectsFound'; import { EmptyState } from '@/components/empty-state/EmptyState'; diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx index 4316e82757..1e3a718bfe 100644 --- a/client/app/visualizations/EditVisualizationDialog.jsx +++ b/client/app/visualizations/EditVisualizationDialog.jsx @@ -6,7 +6,7 @@ import Select from 'antd/lib/select'; import Input from 'antd/lib/input'; import * as Grid from 'antd/lib/grid'; import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; -import { Filters, filterData } from '@/components/Filters'; +import Filters, { filterData } from '@/components/Filters'; import notification from '@/services/notification'; import { Visualization } from '@/services/visualization'; import recordEvent from '@/services/recordEvent'; diff --git a/client/app/visualizations/VisualizationRenderer.jsx b/client/app/visualizations/VisualizationRenderer.jsx index 4e5d524587..deada40a65 100644 --- a/client/app/visualizations/VisualizationRenderer.jsx +++ b/client/app/visualizations/VisualizationRenderer.jsx @@ -3,7 +3,7 @@ import React, { useState, useMemo, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { react2angular } from 'react2angular'; import useQueryResult from '@/lib/hooks/useQueryResult'; -import { Filters, FiltersType, filterData } from '@/components/Filters'; +import Filters, { FiltersType, filterData } from '@/components/Filters'; import { registeredVisualizations, VisualizationType } from './index'; function combineFilters(localFilters, globalFilters) { From 80114ef136d8bcd36140e3827a74ab9c0873c971 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 30 Oct 2019 19:36:37 -0300 Subject: [PATCH 31/39] Keep edit state when changing resolution --- client/app/pages/dashboards/useDashboard.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 5a0bb46a09..0efcdc4a18 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -84,12 +84,6 @@ function useEditModeHandler(canEditDashboard, widgets) { updateUrlSearch('edit', editingLayout ? true : null); }, [editingLayout]); - useEffect(() => { - if (!canEditDashboard && editingLayout) { - setEditingLayout(false); - } - }, [canEditDashboard, editingLayout]); - useEffect(() => { if (doneBtnClickedWhileSaving && dashboardStatus === DashboardStatusEnum.SAVED) { setDoneBtnClickedWhileSaving(false); @@ -99,6 +93,7 @@ function useEditModeHandler(canEditDashboard, widgets) { const saveDashboardLayout = useCallback((positions) => { if (!canEditDashboard) { + setDashboardStatus(DashboardStatusEnum.SAVED); return; } @@ -145,7 +140,7 @@ function useEditModeHandler(canEditDashboard, widgets) { }, [dashboardStatus, canEditDashboard]); return { - editingLayout, + editingLayout: canEditDashboard && editingLayout, setEditingLayout: setEditing, saveDashboardLayout: editingLayout ? saveDashboardLayoutDebounced : saveDashboardLayout, retrySaveDashboardLayout, From 05e8b247755c0c9dc30942d9ee19818b7834532b Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 30 Oct 2019 20:20:48 -0300 Subject: [PATCH 32/39] Bug fix: Dashboard Level Filters not updating --- client/app/pages/dashboards/DashboardPage.jsx | 5 ++--- client/app/pages/dashboards/useDashboard.js | 17 ++++++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 5f35688e28..3366d57e79 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -246,13 +246,12 @@ DashboardEditControl.propTypes = { }; function DashboardSettings({ dashboardOptions }) { - const { dashboard, updateDashboard, loadDashboard } = dashboardOptions; + const { dashboard, updateDashboard } = dashboardOptions; return (
updateDashboard({ dashboard_filters_enabled: target.checked }) - .then(() => loadDashboard())} + onChange={({ target }) => updateDashboard({ dashboard_filters_enabled: target.checked })} > Use Dashboard Level Filters diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 0efcdc4a18..368d9ed322 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -182,12 +182,11 @@ function useDashboard(dashboardData) { if (includeVersion) { data = { ...data, version: dashboard.version }; } - return Dashboard.save( - data, - updatedDashboard => setDashboard(currentDashboard => extend({}, + return Dashboard.save(data).$promise + .then(updatedDashboard => setDashboard(currentDashboard => extend({}, currentDashboard, - pick(updatedDashboard, keys(data)))), - (error) => { + pick(updatedDashboard, keys(data))))) + .catch((error) => { if (error.status === 403) { notification.error('Dashboard update failed', 'Permission Denied.'); } else if (error.status === 409) { @@ -197,8 +196,7 @@ function useDashboard(dashboardData) { { duration: null }, ); } - }, - ).$promise; + }); }, [dashboard]); const togglePublished = useCallback( @@ -290,6 +288,11 @@ function useDashboard(dashboardData) { loadDashboard(); }, [dashboardData]); + // reload dashboard when filter option changes + useEffect(() => { + loadDashboard(); + }, [dashboard.dashboard_filters_enabled]); + return { dashboard, globalParameters, From b434f03da16a0807dae05c96f7f9402ac6ac7c74 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Wed, 13 Nov 2019 22:15:26 -0300 Subject: [PATCH 33/39] Remove prepareWidgetsForDashboard --- client/app/services/dashboard.js | 47 +------------------------------- 1 file changed, 1 insertion(+), 46 deletions(-) diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js index 86869c5e70..4be6b612cf 100644 --- a/client/app/services/dashboard.js +++ b/client/app/services/dashboard.js @@ -33,50 +33,6 @@ export function collectDashboardFilters(dashboard, queryResults, urlParams) { return _.values(filters); } -function prepareWidgetsForDashboard(widgets) { - // Default height for auto-height widgets. - // Compute biggest widget size and choose between it and some magic number. - // This value should be big enough so auto-height widgets will not overlap other ones. - const defaultWidgetSizeY = - Math.max( - _ - .chain(widgets) - .map(w => w.options.position.sizeY) - .max() - .value(), - 20, - ) + 5; - - // Fix layout: - // 1. sort and group widgets by row - // 2. update position of widgets in each row - place it right below - // biggest widget from previous row - _.chain(widgets) - .sortBy(widget => widget.options.position.row) - .groupBy(widget => widget.options.position.row) - .reduce((row, widgetsAtRow) => { - let height = 1; - _.each(widgetsAtRow, (widget) => { - height = Math.max( - height, - widget.options.position.autoHeight ? defaultWidgetSizeY : widget.options.position.sizeY, - ); - widget.options.position.row = row; - if (widget.options.position.sizeY < 1) { - widget.options.position.sizeY = defaultWidgetSizeY; - } - }); - return row + height; - }, 0) - .value(); - - // Sort widgets by updated column and row value - widgets = _.sortBy(widgets, widget => widget.options.position.col); - widgets = _.sortBy(widgets, widget => widget.options.position.row); - - return widgets; -} - function calculateNewWidgetPosition(existingWidgets, newWidget) { const width = _.extend({ sizeX: dashboardGridOptions.defaultSizeX }, _.extend({}, newWidget.options).position).sizeX; @@ -125,7 +81,7 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) { function DashboardService($resource, $http, $location, currentUser) { function prepareDashboardWidgets(widgets) { - return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget))); + return _.map(widgets, widget => new Widget(widget)); } function transformSingle(dashboard) { @@ -187,7 +143,6 @@ function DashboardService($resource, $http, $location, currentUser) { }; resource.prepareDashboardWidgets = prepareDashboardWidgets; - resource.prepareWidgetsForDashboard = prepareWidgetsForDashboard; resource.prototype.getParametersDefs = function getParametersDefs() { const globalParams = {}; const queryParams = $location.search(); From b5db0ee1be92c1de5de0f134ded92a83daef6088 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 14 Nov 2019 07:33:12 -0300 Subject: [PATCH 34/39] Revert "Remove prepareWidgetsForDashboard" This reverts commit b434f03da16a0807dae05c96f7f9402ac6ac7c74. --- client/app/services/dashboard.js | 47 +++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js index 4be6b612cf..86869c5e70 100644 --- a/client/app/services/dashboard.js +++ b/client/app/services/dashboard.js @@ -33,6 +33,50 @@ export function collectDashboardFilters(dashboard, queryResults, urlParams) { return _.values(filters); } +function prepareWidgetsForDashboard(widgets) { + // Default height for auto-height widgets. + // Compute biggest widget size and choose between it and some magic number. + // This value should be big enough so auto-height widgets will not overlap other ones. + const defaultWidgetSizeY = + Math.max( + _ + .chain(widgets) + .map(w => w.options.position.sizeY) + .max() + .value(), + 20, + ) + 5; + + // Fix layout: + // 1. sort and group widgets by row + // 2. update position of widgets in each row - place it right below + // biggest widget from previous row + _.chain(widgets) + .sortBy(widget => widget.options.position.row) + .groupBy(widget => widget.options.position.row) + .reduce((row, widgetsAtRow) => { + let height = 1; + _.each(widgetsAtRow, (widget) => { + height = Math.max( + height, + widget.options.position.autoHeight ? defaultWidgetSizeY : widget.options.position.sizeY, + ); + widget.options.position.row = row; + if (widget.options.position.sizeY < 1) { + widget.options.position.sizeY = defaultWidgetSizeY; + } + }); + return row + height; + }, 0) + .value(); + + // Sort widgets by updated column and row value + widgets = _.sortBy(widgets, widget => widget.options.position.col); + widgets = _.sortBy(widgets, widget => widget.options.position.row); + + return widgets; +} + function calculateNewWidgetPosition(existingWidgets, newWidget) { const width = _.extend({ sizeX: dashboardGridOptions.defaultSizeX }, _.extend({}, newWidget.options).position).sizeX; @@ -81,7 +125,7 @@ function calculateNewWidgetPosition(existingWidgets, newWidget) { function DashboardService($resource, $http, $location, currentUser) { function prepareDashboardWidgets(widgets) { - return _.map(widgets, widget => new Widget(widget)); + return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget))); } function transformSingle(dashboard) { @@ -143,6 +187,7 @@ function DashboardService($resource, $http, $location, currentUser) { }; resource.prepareDashboardWidgets = prepareDashboardWidgets; + resource.prepareWidgetsForDashboard = prepareWidgetsForDashboard; resource.prototype.getParametersDefs = function getParametersDefs() { const globalParams = {}; const queryParams = $location.search(); From f5ee64a8235e1504bd3078367710a2958ae1f7c4 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 14 Nov 2019 07:35:32 -0300 Subject: [PATCH 35/39] Avoid saving layout changes out of editing mode --- client/app/pages/dashboards/DashboardPage.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index 3366d57e79..e558f43546 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -333,7 +333,7 @@ function DashboardComponent(props) { widgets={dashboard.widgets} filters={filters} isEditing={editingLayout} - onLayoutChange={saveDashboardLayout} + onLayoutChange={editingLayout ? saveDashboardLayout : () => {}} onBreakpointChange={setGridDisabled} onLoadWidget={loadWidget} onRefreshWidget={refreshWidget} From 42a9f3d04a54005b5638b64694b8abfaae464a96 Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Thu, 5 Dec 2019 08:57:20 -0300 Subject: [PATCH 36/39] Apply policy for enabled refresh rates --- client/app/pages/dashboards/DashboardPage.jsx | 10 ++++++---- client/app/pages/dashboards/useDashboard.js | 19 +++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/client/app/pages/dashboards/DashboardPage.jsx b/client/app/pages/dashboards/DashboardPage.jsx index e558f43546..090569cf1f 100644 --- a/client/app/pages/dashboards/DashboardPage.jsx +++ b/client/app/pages/dashboards/DashboardPage.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import cx from 'classnames'; -import { map, isEmpty } from 'lodash'; +import { map, isEmpty, includes } from 'lodash'; import { react2angular } from 'react2angular'; import Button from 'antd/lib/button'; import Checkbox from 'antd/lib/checkbox'; @@ -21,6 +21,7 @@ import recordEvent from '@/services/recordEvent'; import { $route } from '@/services/ng'; import getTags from '@/services/getTags'; import { clientConfig } from '@/services/auth'; +import { policy } from '@/services/policy'; import { durationHumanize } from '@/filters'; import PromiseRejectionError from '@/lib/promise-rejection-error'; import useDashboard, { DashboardStatusEnum } from './useDashboard'; @@ -68,7 +69,8 @@ DashboardPageTitle.propTypes = { }; function RefreshButton({ dashboardOptions }) { - const { refreshRate, setRefreshRate, refreshing, refreshDashboard } = dashboardOptions; + const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardOptions; + const allowedIntervals = policy.getDashboardRefreshIntervals(); const refreshRateOptions = clientConfig.dashboardRefreshIntervals; const onRefreshRateSelected = ({ key }) => { const parsedRefreshRate = parseFloat(key); @@ -76,7 +78,7 @@ function RefreshButton({ dashboardOptions }) { setRefreshRate(parsedRefreshRate); refreshDashboard(); } else { - setRefreshRate(null); + disableRefreshRate(); } }; return ( @@ -96,7 +98,7 @@ function RefreshButton({ dashboardOptions }) { overlay={( {refreshRateOptions.map(option => ( - + {durationHumanize(option)} ))} diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 368d9ed322..35ad721393 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -1,11 +1,12 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { isEmpty, isNaN, includes, compact, map, has, pick, keys, - extend, every, find, debounce, isMatch, pickBy } from 'lodash'; + extend, every, find, debounce, isMatch, pickBy, max, min } from 'lodash'; import notification from '@/services/notification'; import { $location, $rootScope } from '@/services/ng'; import { Dashboard, collectDashboardFilters } from '@/services/dashboard'; import { currentUser } from '@/services/auth'; import recordEvent from '@/services/recordEvent'; +import { policy } from '@/services/policy'; import AddWidgetDialog from '@/components/dashboards/AddWidgetDialog'; import TextboxDialog from '@/components/dashboards/TextboxDialog'; import PermissionsEditorDialog from '@/components/permissions-editor/PermissionsEditorDialog'; @@ -44,9 +45,14 @@ function getChangedPositions(widgets, nextPositions = {}) { }); } +function getLimitedRefreshRate(refreshRate) { + const allowedIntervals = policy.getDashboardRefreshIntervals(); + return max([30, min(allowedIntervals), refreshRate]); +} + function getRefreshRateFromUrl() { const refreshRate = parseFloat($location.search().refresh); - return isNaN(refreshRate) ? null : Math.max(30, refreshRate); + return isNaN(refreshRate) ? null : getLimitedRefreshRate(refreshRate); } function useFullscreenHandler() { @@ -71,7 +77,11 @@ function useRefreshRateHandler(refreshDashboard) { } }, [refreshRate]); - return [refreshRate, setRefreshRate]; + return [ + refreshRate, + rate => setRefreshRate(getLimitedRefreshRate(rate)), + () => setRefreshRate(null), + ]; } function useEditModeHandler(canEditDashboard, widgets) { @@ -278,7 +288,7 @@ function useDashboard(dashboardData) { }); }, [dashboard]); - const [refreshRate, setRefreshRate] = useRefreshRateHandler(refreshDashboard); + const [refreshRate, setRefreshRate, disableRefreshRate] = useRefreshRateHandler(refreshDashboard); const [fullscreen, toggleFullscreen] = useFullscreenHandler(); const editModeHandler = useEditModeHandler(!gridDisabled && canEditDashboard, dashboard.widgets); @@ -310,6 +320,7 @@ function useDashboard(dashboardData) { canEditDashboard, refreshRate, setRefreshRate, + disableRefreshRate, ...editModeHandler, gridDisabled, setGridDisabled, From 93ed21a506fabf3d1a239aff5907eb03f208dc8e Mon Sep 17 00:00:00 2001 From: Gabriel Dutra Date: Tue, 17 Dec 2019 09:48:29 -0300 Subject: [PATCH 37/39] Disable loadDashboard deps --- client/app/pages/dashboards/useDashboard.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js index 35ad721393..0949d6aad1 100644 --- a/client/app/pages/dashboards/useDashboard.js +++ b/client/app/pages/dashboards/useDashboard.js @@ -75,7 +75,7 @@ function useRefreshRateHandler(refreshDashboard) { const refreshTimer = setInterval(refreshDashboard, refreshRate * 1000); return () => clearInterval(refreshTimer); } - }, [refreshRate]); + }, [refreshDashboard, refreshRate]); return [ refreshRate, @@ -222,7 +222,7 @@ function useDashboard(dashboardData) { setDashboard(currentDashboard => extend({}, currentDashboard)); return widget.load(forceRefresh) .finally(() => setDashboard(currentDashboard => extend({}, currentDashboard))); - }, [dashboard]); + }, []); const refreshWidget = useCallback(widget => loadWidget(widget, true), [loadWidget]); @@ -255,7 +255,7 @@ function useDashboard(dashboardData) { const archiveDashboard = useCallback(() => { recordEvent('archive', 'dashboard', dashboard.id); dashboard.$delete().then(() => loadDashboard()); - }, [dashboard, updateDashboard]); + }, [dashboard]); // eslint-disable-line react-hooks/exhaustive-deps const showShareDashboardDialog = useCallback(() => { ShareDashboardDialog.showModal({ @@ -296,12 +296,12 @@ function useDashboard(dashboardData) { setDashboard(dashboardData); document.title = dashboardData.name; loadDashboard(); - }, [dashboardData]); + }, [dashboardData]); // eslint-disable-line react-hooks/exhaustive-deps // reload dashboard when filter option changes useEffect(() => { loadDashboard(); - }, [dashboard.dashboard_filters_enabled]); + }, [dashboard.dashboard_filters_enabled]); // eslint-disable-line react-hooks/exhaustive-deps return { dashboard, From cc309ca2cc0c3d9d1b6a868aa23dd920eeef43b1 Mon Sep 17 00:00:00 2001 From: "restyled-io[bot]" <32688539+restyled-io[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2019 09:52:09 -0300 Subject: [PATCH 38/39] Restyled by prettier (#4459) --- client/app/components/FavoritesControl.jsx | 3 +- client/app/pages/dashboards/DashboardPage.jsx | 201 ++++++----- .../pages/dashboards/PublicDashboardPage.jsx | 62 ++-- client/app/pages/dashboards/useDashboard.js | 314 ++++++++++-------- client/app/services/dashboard.js | 2 +- client/app/services/widget.js | 27 +- .../integration/dashboard/dashboard_spec.js | 122 +++---- .../integration/dashboard/sharing_spec.js | 171 +++++----- .../integration/dashboard/textbox_spec.js | 156 ++++----- .../integration/dashboard/widget_spec.js | 179 +++++----- client/cypress/support/dashboard/index.js | 32 +- 11 files changed, 682 insertions(+), 587 deletions(-) diff --git a/client/app/components/FavoritesControl.jsx b/client/app/components/FavoritesControl.jsx index 8a5bb5f8c8..60860a5c82 100644 --- a/client/app/components/FavoritesControl.jsx +++ b/client/app/components/FavoritesControl.jsx @@ -39,8 +39,7 @@ export class FavoritesControl extends React.Component { this.toggleItem(event, item, onChange)} - > + onClick={event => this.toggleItem(event, item, onChange)}>