diff --git a/client/app/components/ErrorBoundary.jsx b/client/app/components/ErrorBoundary.jsx
new file mode 100644
index 0000000000..c6780799db
--- /dev/null
+++ b/client/app/components/ErrorBoundary.jsx
@@ -0,0 +1,71 @@
+import { isFunction } from "lodash";
+import React from "react";
+import PropTypes from "prop-types";
+import debug from "debug";
+import Alert from "antd/lib/alert";
+
+const logger = debug("redash:errors");
+
+export const ErrorBoundaryContext = React.createContext({
+ handleError: error => {
+ throw error;
+ },
+ reset: () => {},
+});
+
+export function ErrorMessage({ children }) {
+ return ;
+}
+
+ErrorMessage.propTypes = {
+ children: PropTypes.node,
+};
+
+ErrorMessage.defaultProps = {
+ children: "Something went wrong.",
+};
+
+export default class ErrorBoundary extends React.Component {
+ static propTypes = {
+ children: PropTypes.node,
+ renderError: PropTypes.func, // error => ReactNode
+ };
+
+ static defaultProps = {
+ children: null,
+ renderError: null,
+ };
+
+ state = { error: null };
+
+ handleError = error => {
+ this.setState(this.constructor.getDerivedStateFromError(error));
+ this.componentDidCatch(error, null);
+ };
+
+ reset = () => {
+ this.setState({ error: null });
+ };
+
+ static getDerivedStateFromError(error) {
+ return { error };
+ }
+
+ componentDidCatch(error, errorInfo) {
+ logger(error, errorInfo);
+ }
+
+ render() {
+ const { renderError, children } = this.props;
+ const { error } = this.state;
+
+ if (error) {
+ if (isFunction(renderError)) {
+ return renderError(error);
+ }
+ return ;
+ }
+
+ return {children};
+ }
+}
diff --git a/client/app/visualizations/EditVisualizationDialog.jsx b/client/app/visualizations/EditVisualizationDialog.jsx
index 0e5ed19663..ad54881c74 100644
--- a/client/app/visualizations/EditVisualizationDialog.jsx
+++ b/client/app/visualizations/EditVisualizationDialog.jsx
@@ -1,11 +1,12 @@
import { isEqual, extend, map, sortBy, findIndex, filter, pick } from "lodash";
-import React, { useState, useMemo } from "react";
+import React, { useState, useMemo, useRef, useEffect } from "react";
import PropTypes from "prop-types";
import Modal from "antd/lib/modal";
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 ErrorBoundary, { ErrorMessage } from "@/components/ErrorBoundary";
import Filters, { filterData } from "@/components/Filters";
import notification from "@/services/notification";
import { Visualization } from "@/services/visualization";
@@ -63,6 +64,8 @@ function confirmDialogClose(isDirty) {
}
function EditVisualizationDialog({ dialog, visualization, query, queryResult }) {
+ const errorHandlerRef = useRef();
+
const isNew = !visualization;
const data = useQueryResult(queryResult);
@@ -94,6 +97,12 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
const [saveInProgress, setSaveInProgress] = useState(false);
+ useEffect(() => {
+ if (errorHandlerRef.current) {
+ errorHandlerRef.current.reset();
+ }
+ }, [data, options]);
+
function onTypeChanged(newType) {
setType(newType);
@@ -188,13 +197,17 @@ function EditVisualizationDialog({ dialog, visualization, query, queryResult })
-
+ Error while rendering visualization.}>
+
+
diff --git a/client/app/visualizations/VisualizationRenderer.jsx b/client/app/visualizations/VisualizationRenderer.jsx
index 0a5ef2f7f0..3b45df923c 100644
--- a/client/app/visualizations/VisualizationRenderer.jsx
+++ b/client/app/visualizations/VisualizationRenderer.jsx
@@ -3,6 +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 ErrorBoundary, { ErrorMessage } from "@/components/ErrorBoundary";
import Filters, { FiltersType, filterData } from "@/components/Filters";
import { registeredVisualizations, VisualizationType } from "./index";
@@ -28,6 +29,7 @@ export function VisualizationRenderer(props) {
const data = useQueryResult(props.queryResult);
const [filters, setFilters] = useState(data.filters);
const lastOptions = useRef();
+ const errorHandlerRef = useRef();
// Reset local filters when query results updated
useEffect(() => {
@@ -60,18 +62,28 @@ export function VisualizationRenderer(props) {
}
lastOptions.current = options;
+ useEffect(() => {
+ if (errorHandlerRef.current) {
+ errorHandlerRef.current.reset();
+ }
+ }, [props.visualization.options, data]);
+
return (
- {showFilters &&
}
-
-
-
+
Error while rendering visualization.}>
+ {showFilters && }
+
+
+
+
);
}
diff --git a/client/app/visualizations/chart/Renderer/PlotlyChart.jsx b/client/app/visualizations/chart/Renderer/PlotlyChart.jsx
index d6b2ff2e9e..775d24c264 100644
--- a/client/app/visualizations/chart/Renderer/PlotlyChart.jsx
+++ b/client/app/visualizations/chart/Renderer/PlotlyChart.jsx
@@ -1,42 +1,65 @@
import { isArray, isObject } from "lodash";
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useContext } from "react";
+import { ErrorBoundaryContext } from "@/components/ErrorBoundary";
import { RendererPropTypes } from "@/visualizations";
import resizeObserver from "@/services/resizeObserver";
import getChartData from "../getChartData";
import { Plotly, prepareData, prepareLayout, updateData, applyLayoutFixes } from "../plotly";
+function catchErrors(func, errorHandler) {
+ return (...args) => {
+ try {
+ return func(...args);
+ } catch (error) {
+ errorHandler.handleError(error);
+ }
+ };
+}
+
export default function PlotlyChart({ options, data }) {
const [container, setContainer] = useState(null);
+ const errorHandler = useContext(ErrorBoundaryContext);
- useEffect(() => {
- if (container) {
- const plotlyOptions = { showLink: false, displaylogo: false };
-
- const chartData = getChartData(data.rows, options);
- const plotlyData = prepareData(chartData, options);
- const plotlyLayout = prepareLayout(container, options, plotlyData);
-
- // It will auto-purge previous graph
- Plotly.newPlot(container, plotlyData, plotlyLayout, plotlyOptions).then(() => {
- applyLayoutFixes(container, plotlyLayout, (e, u) => Plotly.relayout(e, u));
- });
-
- container.on("plotly_restyle", updates => {
- // This event is triggered if some plotly data/layout has changed.
- // We need to catch only changes of traces visibility to update stacking
- if (isArray(updates) && isObject(updates[0]) && updates[0].visible) {
- updateData(plotlyData, options);
- Plotly.relayout(container, plotlyLayout);
- }
- });
-
- const unwatch = resizeObserver(container, () => {
- applyLayoutFixes(container, plotlyLayout, (e, u) => Plotly.relayout(e, u));
- });
- return unwatch;
- }
- }, [options, data, container]);
+ useEffect(
+ catchErrors(() => {
+ if (container) {
+ const plotlyOptions = { showLink: false, displaylogo: false };
+
+ const chartData = getChartData(data.rows, options);
+ const plotlyData = prepareData(chartData, options);
+ const plotlyLayout = prepareLayout(container, options, plotlyData);
+
+ // It will auto-purge previous graph
+ Plotly.newPlot(container, plotlyData, plotlyLayout, plotlyOptions).then(
+ catchErrors(() => {
+ applyLayoutFixes(container, plotlyLayout, (e, u) => Plotly.relayout(e, u));
+ }, errorHandler)
+ );
+
+ container.on(
+ "plotly_restyle",
+ catchErrors(updates => {
+ // This event is triggered if some plotly data/layout has changed.
+ // We need to catch only changes of traces visibility to update stacking
+ if (isArray(updates) && isObject(updates[0]) && updates[0].visible) {
+ updateData(plotlyData, options);
+ Plotly.relayout(container, plotlyLayout);
+ }
+ }, errorHandler)
+ );
+
+ const unwatch = resizeObserver(
+ container,
+ catchErrors(() => {
+ applyLayoutFixes(container, plotlyLayout, (e, u) => Plotly.relayout(e, u));
+ }, errorHandler)
+ );
+ return unwatch;
+ }
+ }, errorHandler),
+ [options, data, container]
+ );
// Cleanup when component destroyed
useEffect(() => {
diff --git a/client/app/visualizations/funnel/Renderer/prepareData.js b/client/app/visualizations/funnel/Renderer/prepareData.js
index 6e68d0d66a..425e75cef6 100644
--- a/client/app/visualizations/funnel/Renderer/prepareData.js
+++ b/client/app/visualizations/funnel/Renderer/prepareData.js
@@ -1,4 +1,14 @@
-import { map, maxBy, sortBy } from "lodash";
+import { map, maxBy, sortBy, toString } from "lodash";
+import moment from "moment";
+import { clientConfig } from "@/services/auth";
+
+function stepValueToString(value) {
+ if (moment.isMoment(value)) {
+ const format = clientConfig.dateTimeFormat || "DD/MM/YYYY HH:mm";
+ return value.format(format);
+ }
+ return toString(value);
+}
export default function prepareData(rows, options) {
if (rows.length === 0 || !options.stepCol.colName || !options.valueCol.colName) {
@@ -14,7 +24,7 @@ export default function prepareData(rows, options) {
}
const data = map(rows, row => ({
- step: row[options.stepCol.colName],
+ step: stepValueToString(row[options.stepCol.colName]),
value: parseFloat(row[options.valueCol.colName]) || 0.0,
}));