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, }));