diff --git a/client/app/assets/less/inc/base.less b/client/app/assets/less/inc/base.less
index b9e3cda7ea..c8a13a510b 100755
--- a/client/app/assets/less/inc/base.less
+++ b/client/app/assets/less/inc/base.less
@@ -234,10 +234,6 @@ text.slicetext {
}
// page
-.page-header--new .btn-favourite, .page-header--new .btn-archive {
- font-size: 19px;
- }
-
.page-title {
display: flex;
align-items: center;
@@ -251,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/assets/less/redash/query.less b/client/app/assets/less/redash/query.less
index 92b24468e7..07e515be52 100644
--- a/client/app/assets/less/redash/query.less
+++ b/client/app/assets/less/redash/query.less
@@ -246,6 +246,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/BigMessage.jsx b/client/app/components/BigMessage.jsx
index c9ff3f0a9c..bbd4ccf9c9 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/FavoritesControl.jsx b/client/app/components/FavoritesControl.jsx
index a0475b413b..60860a5c82 100644
--- a/client/app/components/FavoritesControl.jsx
+++ b/client/app/components/FavoritesControl.jsx
@@ -36,7 +36,10 @@ export class FavoritesControl extends React.Component {
const icon = item.is_favorite ? "fa fa-star" : "fa fa-star-o";
const title = item.is_favorite ? "Remove from favorites" : "Add to favorites";
return (
- this.toggleItem(event, item, onChange)}>
+ this.toggleItem(event, item, onChange)}>
);
diff --git a/client/app/components/Filters.jsx b/client/app/components/Filters.jsx
index d24a2d000f..cc439458e6 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";
@@ -67,7 +66,7 @@ export function filterData(rows, filters = []) {
return result;
}
-export function Filters({ filters, onChange }) {
+function Filters({ filters, onChange }) {
if (filters.length === 0) {
return null;
}
@@ -138,8 +137,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 a5eebdc313..79c3f69cd6 100644
--- a/client/app/components/HelpTrigger.jsx
+++ b/client/app/components/HelpTrigger.jsx
@@ -5,7 +5,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 2bb08967a5..c60744065f 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 24676eea0f..f8b2bad169 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 57c40bf464..55212e8d2c 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";
@@ -242,8 +241,4 @@ 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/dashboards/dashboard-grid.less b/client/app/components/dashboards/dashboard-grid.less
index ac58381f4c..bc8e79a548 100644
--- a/client/app/components/dashboards/dashboard-grid.less
+++ b/client/app/components/dashboards/dashboard-grid.less
@@ -1,3 +1,118 @@
+.dashboard-wrapper {
+ flex-grow: 1;
+ margin-bottom: 70px;
+
+ .layout {
+ margin: -15px -15px 0;
+ }
+
+ .tile {
+ display: flex;
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: auto;
+ height: auto;
+ overflow: hidden;
+ margin: 0;
+ padding: 0;
+ }
+
+ .pivot-table-visualization-container > table,
+ .visualization-renderer > .visualization-renderer-wrapper {
+ overflow: visible;
+ }
+
+ &.preview-mode {
+ .widget-menu-regular {
+ display: block;
+ }
+ .widget-menu-remove {
+ display: none;
+ }
+ }
+
+ &.editing-mode {
+ /* Y axis lines */
+ background: linear-gradient(to right, transparent, transparent 1px, #F6F8F9 1px, #F6F8F9), linear-gradient(to bottom, #B3BABF, #B3BABF 1px, transparent 1px, transparent);
+ background-size: 5px 50px;
+ background-position-y: -8px;
+
+ /* X axis lines */
+ &::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 85px;
+ right: 15px;
+ background: linear-gradient(to bottom, transparent, transparent 2px, #F6F8F9 2px, #F6F8F9 5px), linear-gradient(to left, #B3BABF, #B3BABF 1px, transparent 1px, transparent);
+ background-size: calc((100vw - 15px) / 6) 5px;
+ background-position: -7px 1px;
+ }
+ }
+
+ .dashboard-widget-wrapper:not(.widget-auto-height-enabled) {
+ .visualization-renderer {
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+
+ > .visualization-renderer-wrapper {
+ flex-grow: 1;
+ position: relative;
+ }
+
+ > .filters-wrapper {
+ flex-grow: 0;
+ }
+ }
+
+ .sunburst-visualization-container,
+ .sankey-visualization-container,
+ .map-visualization-container,
+ .word-cloud-visualization-container,
+ .box-plot-deprecated-visualization-container,
+ .chart-visualization-container {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: auto;
+ height: auto;
+ overflow: hidden;
+ }
+
+ .counter-visualization-content {
+ position: absolute;
+ left: 10px;
+ top: 15px;
+ right: 10px;
+ bottom: 15px;
+ height: auto;
+ overflow: hidden;
+ padding: 0;
+ }
+ }
+
+ .widget-auto-height-enabled {
+ .spinner {
+ position: static;
+ }
+
+ .scrollbox {
+ overflow-y: hidden;
+ }
+ }
+}
+
.react-grid-layout {
&.disable-animations {
& > .react-grid-item {
diff --git a/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx b/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx
index 8aa781cfee..7903c543c5 100644
--- a/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx
+++ b/client/app/components/dashboards/dashboard-widget/VisualizationWidget.jsx
@@ -124,7 +124,7 @@ function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) {
}
};
- return (
+ return widgetQueryResult ? (
<>
{!isPublic && !!widgetQueryResult && (
@@ -158,7 +158,7 @@ function VisualizationWidgetFooter({ widget, isPublic, onRefresh, onExpand }) {
>
- );
+ ) : null;
}
VisualizationWidgetFooter.propTypes = {
diff --git a/client/app/components/items-list/components/EmptyState.jsx b/client/app/components/items-list/components/EmptyState.jsx
index edc5469c05..3c64e7465b 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 9d83ecb11a..c21d7f72de 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 e781d6ae37..3cb8a98428 100644
--- a/client/app/components/tags-control/TagsControl.jsx
+++ b/client/app/components/tags-control/TagsControl.jsx
@@ -104,7 +104,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 62bfe0273f..a3b00bc08b 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
new file mode 100644
index 0000000000..69357e5d56
--- /dev/null
+++ b/client/app/pages/dashboards/DashboardPage.jsx
@@ -0,0 +1,410 @@
+import React, { useState, useEffect } from "react";
+import PropTypes from "prop-types";
+import cx from "classnames";
+import { map, isEmpty, includes } 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";
+import Modal from "antd/lib/modal";
+import Tooltip from "antd/lib/tooltip";
+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";
+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";
+
+import "./DashboardPage.less";
+
+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 (
+
+
+
+ updateDashboard({ name })}
+ value={dashboard.name}
+ editor="input"
+ ignoreBlanks
+ />
+
+
+
updateDashboard({ tags })}
+ />
+
+ );
+}
+
+DashboardPageTitle.propTypes = {
+ dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+function RefreshButton({ dashboardOptions }) {
+ const { refreshRate, setRefreshRate, disableRefreshRate, refreshing, refreshDashboard } = dashboardOptions;
+ const allowedIntervals = policy.getDashboardRefreshIntervals();
+ const refreshRateOptions = clientConfig.dashboardRefreshIntervals;
+ const onRefreshRateSelected = ({ key }) => {
+ const parsedRefreshRate = parseFloat(key);
+ if (parsedRefreshRate) {
+ setRefreshRate(parsedRefreshRate);
+ refreshDashboard();
+ } else {
+ disableRefreshRate();
+ }
+ };
+ return (
+
+
+
+
+
+ {refreshRateOptions.map(option => (
+
+ {durationHumanize(option)}
+
+ ))}
+ {refreshRate && Disable auto refresh}
+
+ }>
+
+
+
+ );
+}
+
+RefreshButton.propTypes = {
+ dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+function DashboardMoreOptionsButton({ dashboardOptions }) {
+ const {
+ dashboard,
+ setEditingLayout,
+ togglePublished,
+ archiveDashboard,
+ managePermissions,
+ gridDisabled,
+ } = 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 (
+
+
+ setEditingLayout(true)}>Edit
+
+ {clientConfig.showPermissionsControl && (
+
+ Manage Permissions
+
+ )}
+ {!dashboard.is_draft && (
+
+ Unpublish
+
+ )}
+
+ Archive
+
+
+ }>
+
+
+ );
+}
+
+DashboardMoreOptionsButton.propTypes = {
+ dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+function DashboardControl({ dashboardOptions }) {
+ const {
+ dashboard,
+ togglePublished,
+ canEditDashboard,
+ fullscreen,
+ toggleFullscreen,
+ showShareDashboardDialog,
+ } = 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 && (
+
+ {showPublishButton && (
+
+ )}
+ {showRefreshButton && }
+
+ {showFullscreenButton && (
+
+
+
+ )}
+ {showShareButton && (
+
+
+
+ )}
+ {showMoreOptionsButton && }
+
+
+ )}
+
+ );
+}
+
+DashboardControl.propTypes = {
+ dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+function DashboardEditControl({ 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 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+DashboardEditControl.propTypes = {
+ dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+function DashboardSettings({ dashboardOptions }) {
+ const { dashboard, updateDashboard } = dashboardOptions;
+ return (
+
+ updateDashboard({ dashboard_filters_enabled: target.checked })}>
+ 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 (
+
+
+
+
+ );
+}
+
+DashboardHeader.propTypes = {
+ dashboardOptions: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+function DashboardComponent(props) {
+ const dashboardOptions = useDashboard(props.dashboard);
+ const {
+ dashboard,
+ filters,
+ setFilters,
+ loadDashboard,
+ loadWidget,
+ removeWidget,
+ saveDashboardLayout,
+ globalParameters,
+ refreshDashboard,
+ refreshWidget,
+ editingLayout,
+ setGridDisabled,
+ } = dashboardOptions;
+
+ return (
+ <>
+
+ {!isEmpty(globalParameters) && (
+
+ )}
+ {!isEmpty(filters) && (
+
+
+
+ )}
+ {editingLayout && }
+
+ {}}
+ onBreakpointChange={setGridDisabled}
+ onLoadWidget={loadWidget}
+ onRefreshWidget={refreshWidget}
+ onRemoveWidget={removeWidget}
+ onParameterMappingsChange={loadDashboard}
+ />
+
+ {editingLayout && }
+ >
+ );
+}
+
+DashboardComponent.propTypes = {
+ dashboard: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types
+};
+
+function DashboardPage() {
+ const [dashboard, setDashboard] = useState(null);
+
+ useEffect(() => {
+ Dashboard.get({ slug: $route.current.params.dashboardSlug })
+ .$promise.then(dashboardData => {
+ recordEvent("view", "dashboard", dashboardData.id);
+ setDashboard(dashboardData);
+ })
+ .catch(error => {
+ throw new PromiseRejectionError(error);
+ });
+ }, []);
+
+ return {dashboard && }
;
+}
+
+export default function init(ngModule) {
+ ngModule.component("dashboardPage", react2angular(DashboardPage));
+
+ return {
+ "/dashboard/:dashboardSlug": {
+ template: "",
+ reloadOnSearch: false,
+ },
+ };
+}
+
+init.init = true;
diff --git a/client/app/pages/dashboards/DashboardPage.less b/client/app/pages/dashboards/DashboardPage.less
new file mode 100644
index 0000000000..8dcbc4fcfb
--- /dev/null
+++ b/client/app/pages/dashboards/DashboardPage.less
@@ -0,0 +1,143 @@
+@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;
+ line-height: 1.3;
+ font-weight: 500;
+ }
+
+ .profile-image {
+ width: 16px;
+ height: 16px;
+ border-radius: 100%;
+ margin: 3px 5px 0 0;
+ }
+
+ .tags-control a {
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+ }
+
+ &:hover {
+ .tags-control a {
+ opacity: 1;
+ }
+ }
+}
+
+.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;
+ }
+ }
+}
+
+@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/PublicDashboardPage.jsx b/client/app/pages/dashboards/PublicDashboardPage.jsx
new file mode 100644
index 0000000000..debd19cb7e
--- /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, 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/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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/app/pages/dashboards/dashboard.js b/client/app/pages/dashboards/dashboard.js
deleted file mode 100644
index 7d5876635d..0000000000
--- a/client/app/pages/dashboards/dashboard.js
+++ /dev/null
@@ -1,437 +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("dashboardPage", {
- template,
- controller: DashboardCtrl,
- });
-
- return {
- "/dashboard/:dashboardSlug": {
- template: "",
- reloadOnSearch: false,
- },
- };
-}
-
-init.init = true;
diff --git a/client/app/pages/dashboards/dashboard.less b/client/app/pages/dashboards/dashboard.less
deleted file mode 100644
index 43e14b1b28..0000000000
--- a/client/app/pages/dashboards/dashboard.less
+++ /dev/null
@@ -1,280 +0,0 @@
-@import '../../assets/less/inc/variables';
-
-.dashboard-wrapper {
- flex-grow: 1;
- margin-bottom: 70px;
-
- .layout {
- margin: -15px -15px 0;
- }
-
- .tile {
- display: flex;
- position: absolute;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- width: auto;
- height: auto;
- overflow: hidden;
- margin: 0;
- padding: 0;
- }
-
- .pivot-table-visualization-container > table,
- .visualization-renderer > .visualization-renderer-wrapper {
- overflow: visible;
- }
-
- &.preview-mode {
- .widget-menu-regular {
- display: block;
- }
- .widget-menu-remove {
- display: none;
- }
- }
-
- &.editing-mode {
- /* Y axis lines */
- background: linear-gradient(to right, transparent, transparent 1px, #F6F8F9 1px, #F6F8F9), linear-gradient(to bottom, #B3BABF, #B3BABF 1px, transparent 1px, transparent);
- background-size: 5px 50px;
- background-position-y: -8px;
-
- /* X axis lines */
- &::before {
- content: "";
- position: absolute;
- top: 0;
- left: 0;
- bottom: 85px;
- right: 15px;
- background: linear-gradient(to bottom, transparent, transparent 2px, #F6F8F9 2px, #F6F8F9 5px), linear-gradient(to left, #B3BABF, #B3BABF 1px, transparent 1px, transparent);
- background-size: calc((100vw - 15px) / 6) 5px;
- background-position: -7px 1px;
- }
- }
-
- .dashboard-widget-wrapper:not(.widget-auto-height-enabled) {
- .visualization-renderer {
- display: flex;
- flex-direction: column;
- position: absolute;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
-
- > .visualization-renderer-wrapper {
- flex-grow: 1;
- position: relative;
- }
-
- > .filters-wrapper {
- flex-grow: 0;
- }
- }
-
- .sunburst-visualization-container,
- .sankey-visualization-container,
- .map-visualization-container,
- .word-cloud-visualization-container,
- .box-plot-deprecated-visualization-container,
- .chart-visualization-container {
- position: absolute;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- width: auto;
- height: auto;
- overflow: hidden;
- }
-
- .counter-visualization-content {
- position: absolute;
- left: 10px;
- top: 15px;
- right: 10px;
- bottom: 15px;
- height: auto;
- overflow: hidden;
- padding: 0;
- }
- }
-
- .widget-auto-height-enabled {
- .spinner {
- position: static;
- }
-
- .scrollbox {
- overflow-y: hidden;
- }
- }
-}
-
-.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;
-}
-
-.dashboard-header, .page-header--query {
- .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;
-
- .page-title h3 {
- margin-bottom: 0 !important;
- font-size: 18px;
- line-height: 2;
- }
-
- .dashboard__control {
- margin: 5px 0;
- }
- }
-
- favorites-control {
- margin-top: 4px;
- }
- }
-}
-
-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
-****/
-
-// 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 {
- padding-left: 30px;
- }
- }
-
- .btn {
- align-self: center;
- }
-}
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 a95cc5b536..0000000000
--- a/client/app/pages/dashboards/public-dashboard-page.js
+++ /dev/null
@@ -1,100 +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;
diff --git a/client/app/pages/dashboards/useDashboard.js b/client/app/pages/dashboards/useDashboard.js
new file mode 100644
index 0000000000..5fbbee4c0c
--- /dev/null
+++ b/client/app/pages/dashboards/useDashboard.js
@@ -0,0 +1,367 @@
+import { useState, useEffect, useMemo, useCallback } from "react";
+import {
+ isEmpty,
+ isNaN,
+ includes,
+ compact,
+ map,
+ has,
+ pick,
+ keys,
+ 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";
+import { editableMappingsToParameterMappings, synchronizeWidgetTitles } 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();
+}
+
+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 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 getLimitedRefreshRate(refreshRate) {
+ const allowedIntervals = policy.getDashboardRefreshIntervals();
+ return max([30, min(allowedIntervals), refreshRate]);
+}
+
+function getRefreshRateFromUrl() {
+ const refreshRate = parseFloat($location.search().refresh);
+ return isNaN(refreshRate) ? null : getLimitedRefreshRate(refreshRate);
+}
+
+function useFullscreenHandler() {
+ const [fullscreen, setFullscreen] = useState(has($location.search(), "fullscreen"));
+ useEffect(() => {
+ document.querySelector("body").classList.toggle("headless", fullscreen);
+ updateUrlSearch("fullscreen", fullscreen ? true : null);
+ }, [fullscreen]);
+
+ const toggleFullscreen = () => setFullscreen(!fullscreen);
+ return [fullscreen, toggleFullscreen];
+}
+
+function useRefreshRateHandler(refreshDashboard) {
+ const [refreshRate, setRefreshRate] = useState(getRefreshRateFromUrl());
+
+ useEffect(() => {
+ updateUrlSearch("refresh", refreshRate || null);
+ if (refreshRate) {
+ const refreshTimer = setInterval(refreshDashboard, refreshRate * 1000);
+ return () => clearInterval(refreshTimer);
+ }
+ }, [refreshDashboard, refreshRate]);
+
+ return [refreshRate, rate => setRefreshRate(getLimitedRefreshRate(rate)), () => setRefreshRate(null)];
+}
+
+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]);
+
+ useEffect(() => {
+ if (doneBtnClickedWhileSaving && dashboardStatus === DashboardStatusEnum.SAVED) {
+ setDoneBtnClickedWhileSaving(false);
+ setEditingLayout(false);
+ }
+ }, [doneBtnClickedWhileSaving, dashboardStatus]);
+
+ const saveDashboardLayout = useCallback(
+ positions => {
+ if (!canEditDashboard) {
+ setDashboardStatus(DashboardStatusEnum.SAVED);
+ 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: canEditDashboard && 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 [gridDisabled, setGridDisabled] = useState(false);
+ const globalParameters = useMemo(() => dashboard.getParametersDefs(), [dashboard]);
+ const canEditDashboard = useMemo(
+ () =>
+ !dashboard.is_archived &&
+ has(dashboard, "user.id") &&
+ (currentUser.id === dashboard.user.id || currentUser.hasPermission("admin")),
+ [dashboard]
+ );
+ const hasOnlySafeQueries = useMemo(
+ () => every(dashboard.widgets, w => (w.getQuery() ? w.getQuery().is_safe : true)),
+ [dashboard]
+ );
+
+ const managePermissions = useCallback(() => {
+ 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));
+ // for some reason the request uses the id as slug
+ data = { ...data, slug: dashboard.id };
+ if (includeVersion) {
+ data = { ...data, version: dashboard.version };
+ }
+ return Dashboard.save(data)
+ .$promise.then(updatedDashboard =>
+ setDashboard(currentDashboard => extend({}, currentDashboard, pick(updatedDashboard, keys(data))))
+ )
+ .catch(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 }
+ );
+ }
+ });
+ },
+ [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
+ setDashboard(currentDashboard => extend({}, currentDashboard));
+ return widget.load(forceRefresh).finally(() => setDashboard(currentDashboard => extend({}, currentDashboard)));
+ }, []);
+
+ 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(currentDashboard => extend({}, currentDashboard));
+ },
+ [dashboard]
+ );
+
+ const loadDashboard = useCallback(
+ (forceRefresh = false, 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(dashboard.widgets, widget => widget.getQueryResult()));
+ const updatedFilters = collectDashboardFilters(dashboard, queryResults, $location.search());
+ setFilters(updatedFilters);
+ });
+ },
+ [dashboard, loadWidget]
+ );
+
+ const refreshDashboard = useCallback(
+ updatedParameters => {
+ setRefreshing(true);
+ loadDashboard(true, updatedParameters).finally(() => setRefreshing(false));
+ },
+ [loadDashboard]
+ );
+
+ const archiveDashboard = useCallback(() => {
+ recordEvent("archive", "dashboard", dashboard.id);
+ dashboard.$delete().then(() => loadDashboard());
+ }, [dashboard]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const showShareDashboardDialog = useCallback(() => {
+ ShareDashboardDialog.showModal({
+ dashboard,
+ hasOnlySafeQueries,
+ }).result.finally(() => setDashboard(currentDashboard => extend({}, currentDashboard)));
+ }, [dashboard, hasOnlySafeQueries]);
+
+ const showAddTextboxDialog = useCallback(() => {
+ TextboxDialog.showModal({
+ dashboard,
+ onConfirm: text =>
+ dashboard.addWidget(text).then(() => setDashboard(currentDashboard => extend({}, currentDashboard))),
+ });
+ }, [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(() =>
+ setDashboard(currentDashboard => extend({}, currentDashboard))
+ );
+ }),
+ });
+ }, [dashboard]);
+
+ const [refreshRate, setRefreshRate, disableRefreshRate] = useRefreshRateHandler(refreshDashboard);
+ const [fullscreen, toggleFullscreen] = useFullscreenHandler();
+ const editModeHandler = useEditModeHandler(!gridDisabled && canEditDashboard, dashboard.widgets);
+
+ useEffect(() => {
+ setDashboard(dashboardData);
+ loadDashboard();
+ }, [dashboardData]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ document.title = dashboard.name;
+ }, [dashboard.name]);
+
+ // reload dashboard when filter option changes
+ useEffect(() => {
+ loadDashboard();
+ }, [dashboard.dashboard_filters_enabled]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ return {
+ dashboard,
+ globalParameters,
+ refreshing,
+ filters,
+ setFilters,
+ loadDashboard,
+ refreshDashboard,
+ updateDashboard,
+ togglePublished,
+ archiveDashboard,
+ loadWidget,
+ refreshWidget,
+ removeWidget,
+ canEditDashboard,
+ refreshRate,
+ setRefreshRate,
+ disableRefreshRate,
+ ...editModeHandler,
+ gridDisabled,
+ setGridDisabled,
+ fullscreen,
+ toggleFullscreen,
+ showShareDashboardDialog,
+ showAddTextboxDialog,
+ showAddWidgetDialog,
+ managePermissions,
+ };
+}
+
+export default useDashboard;
diff --git a/client/app/pages/queries-list/QueriesListEmptyState.jsx b/client/app/pages/queries-list/QueriesListEmptyState.jsx
index 5b047b6e26..a8c61d83d4 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/services/dashboard.js b/client/app/services/dashboard.js
index 1ea4963190..e972c3e124 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);
@@ -145,6 +145,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: {
diff --git a/client/app/services/widget.js b/client/app/services/widget.js
index 27efc18074..ead77c51ec 100644
--- a/client/app/services/widget.js
+++ b/client/app/services/widget.js
@@ -151,10 +151,12 @@ function WidgetFactory($http, $location, Query) {
.then(result => {
this.loading = false;
this.data = result;
+ return result;
})
.catch(error => {
this.loading = false;
this.data = error;
+ return error;
});
}
@@ -200,22 +202,19 @@ function WidgetFactory($http, $location, Query) {
const queryParams = $location.search();
const localTypes = [WidgetService.MappingType.WidgetLevel, WidgetService.MappingType.StaticValue];
- return map(
- filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0),
- param => {
- const mapping = mappings[param.name];
- const result = param.clone();
- result.title = mapping.title || param.title;
- result.locals = [param];
- result.urlPrefix = `p_w${this.id}_`;
- if (mapping.type === WidgetService.MappingType.StaticValue) {
- result.setValue(mapping.value);
- } else {
- result.fromUrlParams(queryParams);
- }
- return result;
+ return map(filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0), param => {
+ const mapping = mappings[param.name];
+ const result = param.clone();
+ result.title = mapping.title || param.title;
+ result.locals = [param];
+ result.urlPrefix = `p_w${this.id}_`;
+ if (mapping.type === WidgetService.MappingType.StaticValue) {
+ result.setValue(mapping.value);
+ } else {
+ result.fromUrlParams(queryParams);
}
- );
+ return result;
+ });
}
getParameterMappings() {
diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx
index 08e7f68402..0e5ed19663 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 2cc0703a80..0a5ef2f7f0 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) {
diff --git a/client/cypress/integration/dashboard/dashboard_spec.js b/client/cypress/integration/dashboard/dashboard_spec.js
index d9f7f8d297..9bb7cda586 100644
--- a/client/cypress/integration/dashboard/dashboard_spec.js
+++ b/client/cypress/integration/dashboard/dashboard_spec.js
@@ -1,140 +1,138 @@
/* global cy, Cypress */
-import { createDashboard, addTextbox } from '../../support/redash-api';
-import { getWidgetTestId } from '../../support/dashboard';
+import { createDashboard, addTextbox } from "../../support/redash-api";
+import { getWidgetTestId } from "../../support/dashboard";
-describe('Dashboard', () => {
+describe("Dashboard", () => {
beforeEach(() => {
cy.login();
});
- it('creates new dashboard', () => {
- cy.visit('/dashboards');
- cy.getByTestId('CreateButton').click();
+ it("creates new dashboard", () => {
+ cy.visit("/dashboards");
+ cy.getByTestId("CreateButton").click();
cy.get('li[role="menuitem"]')
- .contains('New Dashboard')
+ .contains("New Dashboard")
.click();
cy.server();
- cy.route('POST', 'api/dashboards').as('NewDashboard');
+ cy.route("POST", "api/dashboards").as("NewDashboard");
- cy.getByTestId('CreateDashboardDialog').within(() => {
- cy.getByTestId('DashboardSaveButton').should('be.disabled');
- cy.get('input').type('Foo Bar');
- cy.getByTestId('DashboardSaveButton').click();
+ cy.getByTestId("CreateDashboardDialog").within(() => {
+ cy.getByTestId("DashboardSaveButton").should("be.disabled");
+ cy.get("input").type("Foo Bar");
+ cy.getByTestId("DashboardSaveButton").click();
});
- cy.wait('@NewDashboard').then((xhr) => {
- const slug = Cypress._.get(xhr, 'response.body.slug');
- assert.isDefined(slug, 'Dashboard api call returns slug');
+ cy.wait("@NewDashboard").then(xhr => {
+ const slug = Cypress._.get(xhr, "response.body.slug");
+ assert.isDefined(slug, "Dashboard api call returns slug");
- cy.visit('/dashboards');
- cy.getByTestId('DashboardLayoutContent').within(() => {
- cy.getByTestId(slug).should('exist');
+ cy.visit("/dashboards");
+ cy.getByTestId("DashboardLayoutContent").within(() => {
+ cy.getByTestId(slug).should("exist");
});
});
});
- it('archives dashboard', () => {
- createDashboard('Foo Bar').then(({ slug }) => {
+ it("archives 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')
- .contains('Archive')
+ cy.getByTestId("DashboardMoreButtonMenu")
+ .contains("Archive")
.click();
- cy.get('.label-tag-archived').should('exist');
- cy.visit('/dashboards');
- cy.getByTestId('DashboardLayoutContent').within(() => {
- cy.getByTestId(slug).should('not.exist');
+ cy.get(".ant-modal .ant-btn")
+ .contains("Archive")
+ .click({ force: true });
+ cy.get(".label-tag-archived").should("exist");
+
+ cy.visit("/dashboards");
+ cy.getByTestId("DashboardLayoutContent").within(() => {
+ cy.getByTestId(slug).should("not.exist");
});
});
});
- context('viewport width is at 800px', () => {
- before(function () {
+ context("viewport width is at 800px", () => {
+ before(function() {
cy.login();
- createDashboard('Foo Bar')
+ createDashboard("Foo Bar")
.then(({ slug, id }) => {
this.dashboardUrl = `/dashboard/${slug}`;
this.dashboardEditUrl = `/dashboard/${slug}?edit`;
- return addTextbox(id, 'Hello World!').then(getWidgetTestId);
+ return addTextbox(id, "Hello World!").then(getWidgetTestId);
})
- .then((elTestId) => {
+ .then(elTestId => {
cy.visit(this.dashboardUrl);
- cy.getByTestId(elTestId).as('textboxEl');
+ cy.getByTestId(elTestId).as("textboxEl");
});
});
- beforeEach(function () {
+ beforeEach(function() {
cy.visit(this.dashboardUrl);
cy.viewport(800, 800);
});
- it('shows widgets with full width', () => {
- cy.get('@textboxEl').should(($el) => {
+ it("shows widgets with full width", () => {
+ cy.get("@textboxEl").should($el => {
expect($el.width()).to.eq(770);
});
cy.viewport(801, 800);
- cy.get('@textboxEl').should(($el) => {
+ cy.get("@textboxEl").should($el => {
expect($el.width()).to.eq(378);
});
});
- it('hides edit option', () => {
- cy.getByTestId('DashboardMoreMenu')
+ it("hides edit option", () => {
+ 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');
+ cy.get("@editButton").should("be.visible");
});
- it('disables edit mode', function () {
+ it("disables edit mode", function() {
+ cy.viewport(801, 800);
cy.visit(this.dashboardEditUrl);
- cy.contains('button', 'Done Editing')
- .as('saveButton')
- .should('be.disabled');
+ cy.contains("button", "Done Editing")
+ .as("saveButton")
+ .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");
});
});
- context('viewport width is at 767px', () => {
- before(function () {
+ context("viewport width is at 767px", () => {
+ before(function() {
cy.login();
- createDashboard('Foo Bar').then(({ slug }) => {
+ createDashboard("Foo Bar").then(({ slug }) => {
this.dashboardUrl = `/dashboard/${slug}`;
});
});
- beforeEach(function () {
+ beforeEach(function() {
cy.visit(this.dashboardUrl);
cy.viewport(767, 800);
});
- it('hides menu button', () => {
- cy.get('.dashboard__control').should('exist');
- cy.getByTestId('DashboardMoreMenu').should('not.be.visible');
+ it("hides menu button", () => {
+ cy.get(".dashboard-control").should("exist");
+ 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/integration/dashboard/sharing_spec.js b/client/cypress/integration/dashboard/sharing_spec.js
index 00a5f3f6b5..adba653603 100644
--- a/client/cypress/integration/dashboard/sharing_spec.js
+++ b/client/cypress/integration/dashboard/sharing_spec.js
@@ -1,74 +1,82 @@
/* global cy */
-import { createDashboard, createQuery } from '../../support/redash-api';
-import { editDashboard, shareDashboard, createQueryAndAddWidget } from '../../support/dashboard';
+import { createDashboard, createQuery } from "../../support/redash-api";
+import { editDashboard, shareDashboard, createQueryAndAddWidget } from "../../support/dashboard";
-describe('Dashboard Sharing', () => {
- beforeEach(function () {
+describe("Dashboard Sharing", () => {
+ beforeEach(function() {
cy.login();
- createDashboard('Foo Bar').then(({ slug, id }) => {
+ createDashboard("Foo Bar").then(({ slug, id }) => {
this.dashboardId = id;
this.dashboardUrl = `/dashboard/${slug}`;
});
});
- it('is possible if all queries are safe', function () {
+ it("is possible if all queries are safe", function() {
const options = {
- parameters: [{
- name: 'foo',
- type: 'number',
- }],
+ parameters: [
+ {
+ name: "foo",
+ type: "number",
+ },
+ ],
};
const dashboardUrl = this.dashboardUrl;
createQuery({ options }).then(({ id: queryId }) => {
cy.visit(dashboardUrl);
editDashboard();
- cy.contains('a', 'Add Widget').click();
- cy.getByTestId('AddWidgetDialog').within(() => {
+ cy.getByTestId("AddWidgetButton").click();
+ cy.getByTestId("AddWidgetDialog").within(() => {
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
});
- cy.contains('button', 'Add to Dashboard').click();
- cy.getByTestId('AddWidgetDialog').should('not.exist');
- cy.clickThrough({ button: `
+ cy.contains("button", "Add to Dashboard").click();
+ cy.getByTestId("AddWidgetDialog").should("not.exist");
+ cy.clickThrough(
+ {
+ button: `
Done Editing
Publish
- ` },
- `OpenShareForm
- PublicAccessEnabled`);
+ `,
+ },
+ `OpenShareForm
+ PublicAccessEnabled`
+ );
- cy.getByTestId('SecretAddress').should('exist');
+ cy.getByTestId("SecretAddress").should("exist");
});
});
- describe('is available to unauthenticated users', () => {
- it('when there are no parameters', function () {
+ describe("is available to unauthenticated users", () => {
+ it("when there are no parameters", function() {
const queryData = {
- query: 'select 1',
+ query: "select 1",
};
const position = { autoHeight: false, sizeY: 6 };
createQueryAndAddWidget(this.dashboardId, queryData, { position }).then(() => {
cy.visit(this.dashboardUrl);
- shareDashboard().then((secretAddress) => {
+ shareDashboard().then(secretAddress => {
cy.logout();
cy.visit(secretAddress);
- cy.getByTestId('TableVisualization', { timeout: 10000 }).should('exist');
- cy.percySnapshot('Successfully Shared Unparameterized Dashboard');
+ cy.getByTestId("TableVisualization", { timeout: 10000 }).should("exist");
+ cy.percySnapshot("Successfully Shared Unparameterized Dashboard");
});
});
});
- it('when there are only safe parameters', function () {
+ it("when there are only safe parameters", function() {
const queryData = {
query: "select '{{foo}}'",
options: {
- parameters: [{
- name: 'foo',
- type: 'number',
- value: 1,
- }],
+ parameters: [
+ {
+ name: "foo",
+ type: "number",
+ value: 1,
+ },
+ ],
},
};
@@ -76,78 +84,90 @@ describe('Dashboard Sharing', () => {
createQueryAndAddWidget(this.dashboardId, queryData, { position }).then(() => {
cy.visit(this.dashboardUrl);
- shareDashboard().then((secretAddress) => {
+ shareDashboard().then(secretAddress => {
cy.logout();
cy.visit(secretAddress);
- cy.getByTestId('TableVisualization', { timeout: 10000 }).should('exist');
- cy.percySnapshot('Successfully Shared Parameterized Dashboard');
+ cy.getByTestId("TableVisualization", { timeout: 10000 }).should("exist");
+ cy.percySnapshot("Successfully Shared Parameterized Dashboard");
});
});
});
- it('even when there are suddenly some unsafe parameters', function () {
+ it("even when there are suddenly some unsafe parameters", function() {
const queryData = {
- query: 'select 1',
+ query: "select 1",
};
// start out by creating a dashboard with no parameters & share it
const position = { autoHeight: false, sizeY: 6 };
- createQueryAndAddWidget(this.dashboardId, queryData, { position }).then(() => {
- cy.visit(this.dashboardUrl);
- return shareDashboard();
- }).then((secretAddress) => {
- const unsafeQueryData = {
- query: "select '{{foo}}'",
- options: {
- parameters: [{
- name: 'foo',
- type: 'text',
- value: 'oh snap!',
- }],
- },
- };
-
- // 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(() => {
+ createQueryAndAddWidget(this.dashboardId, queryData, { position })
+ .then(() => {
cy.visit(this.dashboardUrl);
- cy.logout();
- cy.title().should('eq', 'Login to Redash'); // Make sure it's logged out
- cy.visit(secretAddress);
- cy.getByTestId('TableVisualization', { timeout: 10000 }).should('exist');
- cy.contains('.alert', 'This query contains potentially unsafe parameters' +
- ' and cannot be executed on a shared dashboard or an embedded visualization.');
- cy.percySnapshot('Successfully Shared Parameterized Dashboard With Some Unsafe Queries');
+ return shareDashboard();
+ })
+ .then(secretAddress => {
+ const unsafeQueryData = {
+ query: "select '{{foo}}'",
+ options: {
+ parameters: [
+ {
+ name: "foo",
+ type: "text",
+ value: "oh snap!",
+ },
+ ],
+ },
+ };
+
+ // 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.logout();
+ cy.title().should("eq", "Login to Redash"); // Make sure it's logged out
+ cy.visit(secretAddress);
+ cy.getByTestId("TableVisualization", { timeout: 10000 }).should("exist");
+ cy.contains(
+ ".alert",
+ "This query contains potentially unsafe parameters" +
+ " and cannot be executed on a shared dashboard or an embedded visualization."
+ );
+ cy.percySnapshot("Successfully Shared Parameterized Dashboard With Some Unsafe Queries");
+ });
});
- });
});
});
- it('is not possible if some queries are not safe', function () {
+ it("is not possible if some queries are not safe", function() {
const options = {
- parameters: [{
- name: 'foo',
- type: 'text',
- }],
+ parameters: [
+ {
+ name: "foo",
+ type: "text",
+ },
+ ],
};
const dashboardUrl = this.dashboardUrl;
createQuery({ options }).then(({ id: queryId }) => {
cy.visit(dashboardUrl);
editDashboard();
- cy.contains('a', 'Add Widget').click();
- cy.getByTestId('AddWidgetDialog').within(() => {
+ cy.getByTestId("AddWidgetButton").click();
+ cy.getByTestId("AddWidgetDialog").within(() => {
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
});
- cy.contains('button', 'Add to Dashboard').click();
- cy.getByTestId('AddWidgetDialog').should('not.exist');
- cy.clickThrough({ button: `
+ cy.contains("button", "Add to Dashboard").click();
+ cy.getByTestId("AddWidgetDialog").should("not.exist");
+ cy.clickThrough(
+ {
+ button: `
Done Editing
Publish
- ` },
- 'OpenShareForm');
+ `,
+ },
+ "OpenShareForm"
+ );
- cy.getByTestId('PublicAccessEnabled').should('be.disabled');
+ cy.getByTestId("PublicAccessEnabled").should("be.disabled");
});
});
});
diff --git a/client/cypress/integration/dashboard/textbox_spec.js b/client/cypress/integration/dashboard/textbox_spec.js
index 610ac97d54..ccfdc0258e 100644
--- a/client/cypress/integration/dashboard/textbox_spec.js
+++ b/client/cypress/integration/dashboard/textbox_spec.js
@@ -1,144 +1,148 @@
/* global cy */
-import { createDashboard, addTextbox } from '../../support/redash-api';
-import { getWidgetTestId, editDashboard } from '../../support/dashboard';
+import { createDashboard, addTextbox } from "../../support/redash-api";
+import { getWidgetTestId, editDashboard } from "../../support/dashboard";
-describe('Textbox', () => {
- beforeEach(function () {
+describe("Textbox", () => {
+ beforeEach(function() {
cy.login();
- createDashboard('Foo Bar').then(({ slug, id }) => {
+ createDashboard("Foo Bar").then(({ slug, id }) => {
this.dashboardId = id;
this.dashboardUrl = `/dashboard/${slug}`;
});
});
const confirmDeletionInModal = () => {
- cy.get('.ant-modal .ant-btn').contains('Delete').click({ force: true });
+ cy.get(".ant-modal .ant-btn")
+ .contains("Delete")
+ .click({ force: true });
};
- it('adds textbox', function () {
+ it("adds textbox", function() {
cy.visit(this.dashboardUrl);
editDashboard();
- cy.contains('a', 'Add Textbox').click();
- cy.getByTestId('TextboxDialog').within(() => {
- cy.get('textarea').type('Hello World!');
+ cy.getByTestId("AddTextboxButton").click();
+ cy.getByTestId("TextboxDialog").within(() => {
+ cy.get("textarea").type("Hello World!");
});
- cy.contains('button', 'Add to Dashboard').click();
- cy.getByTestId('TextboxDialog').should('not.exist');
- cy.get('.widget-text').should('exist');
+ cy.contains("button", "Add to Dashboard").click();
+ cy.getByTestId("TextboxDialog").should("not.exist");
+ cy.get(".widget-text").should("exist");
});
- it('removes textbox by X button', function () {
- addTextbox(this.dashboardId, 'Hello World!').then(getWidgetTestId).then((elTestId) => {
- cy.visit(this.dashboardUrl);
- editDashboard();
+ it("removes textbox by X button", function() {
+ addTextbox(this.dashboardId, "Hello World!")
+ .then(getWidgetTestId)
+ .then(elTestId => {
+ cy.visit(this.dashboardUrl);
+ editDashboard();
- cy.getByTestId(elTestId)
- .within(() => {
- cy.getByTestId('WidgetDeleteButton').click();
+ cy.getByTestId(elTestId).within(() => {
+ cy.getByTestId("WidgetDeleteButton").click();
});
- confirmDeletionInModal();
- cy.getByTestId(elTestId).should('not.exist');
- });
+ confirmDeletionInModal();
+ cy.getByTestId(elTestId).should("not.exist");
+ });
});
- it('removes textbox by menu', function () {
- addTextbox(this.dashboardId, 'Hello World!').then(getWidgetTestId).then((elTestId) => {
- cy.visit(this.dashboardUrl);
- cy.getByTestId(elTestId)
- .within(() => {
- cy.getByTestId('WidgetDropdownButton')
- .click();
+ it("removes textbox by menu", function() {
+ addTextbox(this.dashboardId, "Hello World!")
+ .then(getWidgetTestId)
+ .then(elTestId => {
+ cy.visit(this.dashboardUrl);
+ cy.getByTestId(elTestId).within(() => {
+ cy.getByTestId("WidgetDropdownButton").click();
});
- cy.getByTestId('WidgetDropdownButtonMenu')
- .contains('Remove from Dashboard')
- .click();
+ cy.getByTestId("WidgetDropdownButtonMenu")
+ .contains("Remove from Dashboard")
+ .click();
- confirmDeletionInModal();
- cy.getByTestId(elTestId).should('not.exist');
- });
+ confirmDeletionInModal();
+ cy.getByTestId(elTestId).should("not.exist");
+ });
});
- it('allows opening menu after removal', function () {
+ it("allows opening menu after removal", function() {
let elTestId1;
- addTextbox(this.dashboardId, 'txb 1')
+ addTextbox(this.dashboardId, "txb 1")
.then(getWidgetTestId)
- .then((elTestId) => {
+ .then(elTestId => {
elTestId1 = elTestId;
- return addTextbox(this.dashboardId, 'txb 2').then(getWidgetTestId);
+ return addTextbox(this.dashboardId, "txb 2").then(getWidgetTestId);
})
- .then((elTestId2) => {
+ .then(elTestId2 => {
cy.visit(this.dashboardUrl);
editDashboard();
// remove 1st textbox and make sure it's gone
cy.getByTestId(elTestId1)
- .as('textbox1')
+ .as("textbox1")
.within(() => {
- cy.getByTestId('WidgetDeleteButton').click();
+ cy.getByTestId("WidgetDeleteButton").click();
});
confirmDeletionInModal();
- cy.get('@textbox1').should('not.exist');
+ cy.get("@textbox1").should("not.exist");
// remove 2nd textbox and make sure it's gone
cy.getByTestId(elTestId2)
- .as('textbox2')
+ .as("textbox2")
.within(() => {
// unclickable https://github.com/getredash/redash/issues/3202
- cy.getByTestId('WidgetDeleteButton').click();
+ cy.getByTestId("WidgetDeleteButton").click();
});
confirmDeletionInModal();
- cy.get('@textbox2').should('not.exist'); // <-- fails because of the bug
+ cy.get("@textbox2").should("not.exist"); // <-- fails because of the bug
});
});
- it('edits textbox', function () {
- addTextbox(this.dashboardId, 'Hello World!').then(getWidgetTestId).then((elTestId) => {
- cy.visit(this.dashboardUrl);
- cy.getByTestId(elTestId)
- .as('textboxEl')
- .within(() => {
- cy.getByTestId('WidgetDropdownButton')
- .click();
- });
+ it("edits textbox", function() {
+ addTextbox(this.dashboardId, "Hello World!")
+ .then(getWidgetTestId)
+ .then(elTestId => {
+ cy.visit(this.dashboardUrl);
+ cy.getByTestId(elTestId)
+ .as("textboxEl")
+ .within(() => {
+ cy.getByTestId("WidgetDropdownButton").click();
+ });
- cy.getByTestId('WidgetDropdownButtonMenu')
- .contains('Edit')
- .click();
-
- const newContent = '[edited]';
- cy.getByTestId('TextboxDialog')
- .should('exist')
- .within(() => {
- cy.get('textarea')
- .clear()
- .type(newContent);
- cy.contains('button', 'Save').click();
- });
+ cy.getByTestId("WidgetDropdownButtonMenu")
+ .contains("Edit")
+ .click();
- cy.get('@textboxEl').should('contain', newContent);
- });
+ const newContent = "[edited]";
+ cy.getByTestId("TextboxDialog")
+ .should("exist")
+ .within(() => {
+ cy.get("textarea")
+ .clear()
+ .type(newContent);
+ cy.contains("button", "Save").click();
+ });
+
+ cy.get("@textboxEl").should("contain", newContent);
+ });
});
- it('renders textbox according to position configuration', function () {
+ it("renders textbox according to position configuration", function() {
const id = this.dashboardId;
const txb1Pos = { col: 0, row: 0, sizeX: 3, sizeY: 2 };
const txb2Pos = { col: 1, row: 1, sizeX: 3, sizeY: 4 };
cy.viewport(1215, 800);
- addTextbox(id, 'x', { position: txb1Pos })
- .then(() => addTextbox(id, 'x', { position: txb2Pos }))
+ addTextbox(id, "x", { position: txb1Pos })
+ .then(() => addTextbox(id, "x", { position: txb2Pos }))
.then(getWidgetTestId)
- .then((elTestId) => {
+ .then(elTestId => {
cy.visit(this.dashboardUrl);
return cy.getByTestId(elTestId);
})
- .should(($el) => {
+ .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..9494c66351 100644
--- a/client/cypress/integration/dashboard/widget_spec.js
+++ b/client/cypress/integration/dashboard/widget_spec.js
@@ -1,180 +1,201 @@
/* global cy */
-import { createDashboard, createQuery } from '../../support/redash-api';
-import { createQueryAndAddWidget, editDashboard, resizeBy } from '../../support/dashboard';
+import { createDashboard, createQuery } from "../../support/redash-api";
+import { createQueryAndAddWidget, editDashboard, resizeBy } from "../../support/dashboard";
-describe('Widget', () => {
- beforeEach(function () {
+describe("Widget", () => {
+ beforeEach(function() {
cy.login();
- createDashboard('Foo Bar').then(({ slug, id }) => {
+ createDashboard("Foo Bar").then(({ slug, id }) => {
this.dashboardId = id;
this.dashboardUrl = `/dashboard/${slug}`;
});
});
const confirmDeletionInModal = () => {
- cy.get('.ant-modal .ant-btn').contains('Delete').click({ force: true });
+ cy.get(".ant-modal .ant-btn")
+ .contains("Delete")
+ .click({ force: true });
};
- it('adds widget', function () {
+ it("adds widget", function() {
createQuery().then(({ id: queryId }) => {
cy.visit(this.dashboardUrl);
editDashboard();
- cy.contains('a', 'Add Widget').click();
- cy.getByTestId('AddWidgetDialog').within(() => {
+ cy.getByTestId("AddWidgetButton").click();
+ cy.getByTestId("AddWidgetDialog").within(() => {
cy.get(`.query-selector-result[data-test="QueryId${queryId}"]`).click();
});
- cy.contains('button', 'Add to Dashboard').click();
- cy.getByTestId('AddWidgetDialog').should('not.exist');
- cy.get('.widget-wrapper').should('exist');
+ cy.contains("button", "Add to Dashboard").click();
+ cy.getByTestId("AddWidgetDialog").should("not.exist");
+ cy.get(".widget-wrapper").should("exist");
});
});
- it('removes widget', function () {
- createQueryAndAddWidget(this.dashboardId).then((elTestId) => {
+ it("removes widget", function() {
+ createQueryAndAddWidget(this.dashboardId).then(elTestId => {
cy.visit(this.dashboardUrl);
editDashboard();
- cy.getByTestId(elTestId)
- .within(() => {
- cy.getByTestId('WidgetDeleteButton').click();
- });
+ cy.getByTestId(elTestId).within(() => {
+ cy.getByTestId("WidgetDeleteButton").click();
+ });
confirmDeletionInModal();
- cy.getByTestId(elTestId).should('not.exist');
+ cy.getByTestId(elTestId).should("not.exist");
});
});
- describe('Auto height for table visualization', () => {
- it('renders correct height for 2 table rows', function () {
+ describe("Auto height for table visualization", () => {
+ it("renders correct height for 2 table rows", function() {
const queryData = {
- query: 'select s.a FROM generate_series(1,2) AS s(a)',
+ query: "select s.a FROM generate_series(1,2) AS s(a)",
};
- createQueryAndAddWidget(this.dashboardId, queryData).then((elTestId) => {
+ createQueryAndAddWidget(this.dashboardId, queryData).then(elTestId => {
cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId)
- .its('0.offsetHeight')
- .should('eq', 235);
+ .its("0.offsetHeight")
+ .should("eq", 235);
});
});
- it('renders correct height for 5 table rows', function () {
+ it("renders correct height for 5 table rows", function() {
const queryData = {
- query: 'select s.a FROM generate_series(1,5) AS s(a)',
+ query: "select s.a FROM generate_series(1,5) AS s(a)",
};
- createQueryAndAddWidget(this.dashboardId, queryData).then((elTestId) => {
+ createQueryAndAddWidget(this.dashboardId, queryData).then(elTestId => {
cy.visit(this.dashboardUrl);
cy.getByTestId(elTestId)
- .its('0.offsetHeight')
- .should('eq', 335);
+ .its("0.offsetHeight")
+ .should("eq", 335);
});
});
- describe('Height behavior on refresh', () => {
- const paramName = 'count';
+ describe("Height behavior on refresh", () => {
+ const paramName = "count";
const queryData = {
query: `select s.a FROM generate_series(1,{{ ${paramName} }}) AS s(a)`,
options: {
- parameters: [{
- title: paramName,
- name: paramName,
- type: 'text',
- }],
+ parameters: [
+ {
+ title: paramName,
+ name: paramName,
+ type: "text",
+ },
+ ],
},
};
- beforeEach(function () {
- createQueryAndAddWidget(this.dashboardId, queryData).then((elTestId) => {
+ beforeEach(function() {
+ createQueryAndAddWidget(this.dashboardId, queryData).then(elTestId => {
cy.visit(this.dashboardUrl);
- cy.getByTestId(elTestId).as('widget').within(() => {
- cy.getByTestId('RefreshButton').as('refreshButton');
- });
+ cy.getByTestId(elTestId)
+ .as("widget")
+ .within(() => {
+ cy.getByTestId("RefreshButton").as("refreshButton");
+ });
cy.getByTestId(`ParameterName-${paramName}`).within(() => {
- cy.getByTestId('TextParamInput').as('paramInput');
+ cy.getByTestId("TextParamInput").as("paramInput");
});
});
});
- it('grows when dynamically adding table rows', () => {
+ it("grows when dynamically adding table rows", () => {
// listen to results
cy.server();
- cy.route('GET', 'api/query_results/*').as('FreshResults');
+ cy.route("GET", "api/query_results/*").as("FreshResults");
// start with 1 table row
- cy.get('@paramInput').clear().type('1');
- cy.getByTestId('ParameterApplyButton').click();
- cy.wait('@FreshResults', { timeout: 10000 });
- cy.get('@widget').invoke('height').should('eq', 285);
+ cy.get("@paramInput")
+ .clear()
+ .type("1");
+ cy.getByTestId("ParameterApplyButton").click();
+ cy.wait("@FreshResults", { timeout: 10000 });
+ cy.get("@widget")
+ .invoke("height")
+ .should("eq", 285);
// add 4 table rows
- cy.get('@paramInput').clear().type('5');
- cy.getByTestId('ParameterApplyButton').click();
- cy.wait('@FreshResults', { timeout: 10000 });
+ cy.get("@paramInput")
+ .clear()
+ .type("5");
+ cy.getByTestId("ParameterApplyButton").click();
+ cy.wait("@FreshResults", { timeout: 10000 });
// expect to height to grow by 1 grid grow
- cy.get('@widget').invoke('height').should('eq', 435);
+ cy.get("@widget")
+ .invoke("height")
+ .should("eq", 435);
});
- it('revokes auto height after manual height adjustment', () => {
+ it("revokes auto height after manual height adjustment", () => {
// listen to results
cy.server();
- cy.route('GET', 'api/query_results/*').as('FreshResults');
+ cy.route("GET", "api/query_results/*").as("FreshResults");
editDashboard();
// start with 1 table row
- cy.get('@paramInput').clear().type('1');
- cy.getByTestId('ParameterApplyButton').click();
- cy.wait('@FreshResults');
- cy.get('@widget').invoke('height').should('eq', 285);
+ cy.get("@paramInput")
+ .clear()
+ .type("1");
+ cy.getByTestId("ParameterApplyButton").click();
+ cy.wait("@FreshResults");
+ cy.get("@widget")
+ .invoke("height")
+ .should("eq", 285);
// resize height by 1 grid row
- resizeBy(cy.get('@widget'), 0, 50)
- .then(() => cy.get('@widget'))
- .invoke('height')
- .should('eq', 335); // resized by 50, , 135 -> 185
+ resizeBy(cy.get("@widget"), 0, 50)
+ .then(() => cy.get("@widget"))
+ .invoke("height")
+ .should("eq", 335); // resized by 50, , 135 -> 185
// add 4 table rows
- cy.get('@paramInput').clear().type('5');
- cy.getByTestId('ParameterApplyButton').click();
- cy.wait('@FreshResults');
+ cy.get("@paramInput")
+ .clear()
+ .type("5");
+ cy.getByTestId("ParameterApplyButton").click();
+ cy.wait("@FreshResults");
// expect height to stay unchanged (would have been 435)
- cy.get('@widget').invoke('height').should('eq', 335);
+ cy.get("@widget")
+ .invoke("height")
+ .should("eq", 335);
});
});
});
- it('sets the correct height of table visualization', function () {
+ it("sets the correct height of table visualization", function() {
const queryData = {
- query: `select '${'loremipsum'.repeat(15)}' FROM generate_series(1,15)`,
+ query: `select '${"loremipsum".repeat(15)}' FROM generate_series(1,15)`,
};
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(() => {
cy.visit(this.dashboardUrl);
- cy.getByTestId('TableVisualization')
- .its('0.offsetHeight')
- .should('eq', 381);
- cy.percySnapshot('Shows correct height of table visualization');
+ cy.getByTestId("TableVisualization")
+ .its("0.offsetHeight")
+ .should("eq", 381);
+ cy.percySnapshot("Shows correct height of table visualization");
});
});
- it('shows fixed pagination for overflowing tabular content ', function () {
+ it("shows fixed pagination for overflowing tabular content ", function() {
const queryData = {
- query: 'select \'lorem ipsum\' FROM generate_series(1,50)',
+ query: "select 'lorem ipsum' FROM generate_series(1,50)",
};
const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(() => {
cy.visit(this.dashboardUrl);
- cy.getByTestId('TableVisualization')
- .next('.ant-pagination.mini')
- .should('be.visible');
- cy.percySnapshot('Shows fixed mini pagination for overflowing tabular content');
+ cy.getByTestId("TableVisualization")
+ .next(".ant-pagination.mini")
+ .should("be.visible");
+ cy.percySnapshot("Shows fixed mini pagination for overflowing tabular content");
});
});
});
diff --git a/client/cypress/support/dashboard/index.js b/client/cypress/support/dashboard/index.js
index cd7b38a0c1..238949bcef 100644
--- a/client/cypress/support/dashboard/index.js
+++ b/client/cypress/support/dashboard/index.js
@@ -1,9 +1,9 @@
/* global cy */
-import { createQuery, addWidget } from '../redash-api';
+import { createQuery, addWidget } from "../redash-api";
const { get } = Cypress._;
-const RESIZE_HANDLE_SELECTOR = '.react-resizable-handle';
+const RESIZE_HANDLE_SELECTOR = ".react-resizable-handle";
export function getWidgetTestId(widget) {
return `WidgetId${widget.id}`;
@@ -11,35 +11,34 @@ export function getWidgetTestId(widget) {
export function createQueryAndAddWidget(dashboardId, queryData = {}, widgetOptions = {}) {
return createQuery(queryData)
- .then((query) => {
- const visualizationId = get(query, 'visualizations.0.id');
- assert.isDefined(visualizationId, 'Query api call returns at least one visualization with id');
+ .then(query => {
+ const visualizationId = get(query, "visualizations.0.id");
+ assert.isDefined(visualizationId, "Query api call returns at least one visualization with id");
return addWidget(dashboardId, visualizationId, widgetOptions);
})
.then(getWidgetTestId);
}
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() {
- cy.clickThrough({ button: 'Publish' },
+ cy.clickThrough(
+ { button: "Publish" },
`OpenShareForm
- PublicAccessEnabled`);
+ PublicAccessEnabled`
+ );
- return cy.getByTestId('SecretAddress').invoke('val');
+ return cy.getByTestId("SecretAddress").invoke("val");
}
export function resizeBy(wrapper, offsetLeft = 0, offsetTop = 0) {
- return wrapper
- .within(() => {
- cy.get(RESIZE_HANDLE_SELECTOR).dragBy(offsetLeft, offsetTop, true);
- });
+ return wrapper.within(() => {
+ cy.get(RESIZE_HANDLE_SELECTOR).dragBy(offsetLeft, offsetTop, true);
+ });
}