Skip to content
This repository has been archived by the owner on Dec 10, 2021. It is now read-only.

Commit

Permalink
feat: add error boundary and responsiveness to SuperChart (#175)
Browse files Browse the repository at this point in the history
* feat: add fallback component

* feat: add superchart shell

* feat: add vx/responsive type declaration

* fix: path and dependencies

* test: add unit tests

* test: add more tests

* docs: add storybook

* test: fix FallBackComponent test

* feat: make fallback accepts width and height

* test: reach 100%

* fix: test

* fix: add more checks

* refactor: rename SuperChartKernel to SuperChartCore

* refactor: separate backward-compatibility code into another wrapper
  • Loading branch information
kristw committed Jun 18, 2019
1 parent 54849c1 commit c17b13d
Show file tree
Hide file tree
Showing 20 changed files with 1,096 additions and 357 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"pretest": "yarn run lint",
"prettier": "beemo prettier \"./packages/*/{src,test,storybook}/**/*.{js,jsx,ts,tsx,json,md}\"",
"release": "yarn run prepare-release && lerna publish && yarn run postrelease",
"test": "yarn run type && yarn run jest",
"test": "yarn run jest",
"test:watch": "yarn run lint:fix && beemo create-config jest --react && jest --watch"
},
"repository": "https://github.com/apache-superset/superset-ui.git",
Expand Down
22 changes: 22 additions & 0 deletions packages/superset-ui-chart/__mocks__/resize-observer-polyfill.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const allCallbacks = [];

export default function ResizeObserver(callback) {
if (callback) {
allCallbacks.push(callback);
}

return {
disconnect: () => {
allCallbacks.splice(allCallbacks.findIndex(callback), 1);
},
observe: () => {},
};
}

const DEFAULT_OUTPUT = [{ contentRect: { height: 300, width: 300 } }];

export function triggerResizeObserver(output = DEFAULT_OUTPUT) {
allCallbacks.forEach(fn => {
fn(output);
});
}
3 changes: 3 additions & 0 deletions packages/superset-ui-chart/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
"dependencies": {
"@types/react": "^16.7.17",
"@types/react-loadable": "^5.4.2",
"@vx/responsive": "^0.0.189",
"prop-types": "^15.6.2",
"react-error-boundary": "^1.2.5",
"react-loadable": "^5.5.0",
"reselect": "^4.0.0"
},
Expand All @@ -40,6 +42,7 @@
"peerDependencies": {
"@superset-ui/connection": "^0.11.0",
"@superset-ui/core": "^0.11.0",
"@superset-ui/dimension": "^0.11.10",
"react": "^15 || ^16"
}
}
34 changes: 34 additions & 0 deletions packages/superset-ui-chart/src/components/FallbackComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react';
import { FallbackPropsWithDimension } from './SuperChart';

export type Props = FallbackPropsWithDimension;

const CONTAINER_STYLE = {
backgroundColor: '#000',
color: '#fff',
overflow: 'auto',
padding: 32,
};

export default function FallbackComponent({ componentStack, error, height, width }: Props) {
return (
<div style={{ ...CONTAINER_STYLE, height, width }}>
<div>
<div>
<b>Oops! An error occured!</b>
</div>
<code>{error ? error.toString() : 'Unknown Error'}</code>
</div>
{componentStack && (
<div>
<b>Stack Trace:</b>
<code>
{componentStack.split('\n').map((row: string) => (
<div key={row}>{row}</div>
))}
</code>
</div>
)}
</div>
);
}
272 changes: 87 additions & 185 deletions packages/superset-ui-chart/src/components/SuperChart.tsx
Original file line number Diff line number Diff line change
@@ -1,209 +1,111 @@
import * as React from 'react';
import { createSelector } from 'reselect';
import getChartComponentRegistry from '../registries/ChartComponentRegistrySingleton';
import getChartTransformPropsRegistry from '../registries/ChartTransformPropsRegistrySingleton';
import ChartProps from '../models/ChartProps';
import createLoadableRenderer, { LoadableRenderer } from './createLoadableRenderer';
import { ChartType } from '../models/ChartPlugin';
import { PreTransformProps, TransformProps, PostTransformProps } from '../types/TransformFunction';
import { HandlerFunction } from '../types/Base';
import React from 'react';
import ErrorBoundary, { ErrorBoundaryProps, FallbackProps } from 'react-error-boundary';
import { parseLength } from '@superset-ui/dimension';
import { ParentSize } from '@vx/responsive';
import SuperChartCore, { Props as SuperChartCoreProps } from './SuperChartCore';
import DefaultFallbackComponent from './FallbackComponent';
import ChartProps, { ChartPropsConfig } from '../models/ChartProps';

const IDENTITY = (x: any) => x;

const EMPTY = () => null;

/* eslint-disable sort-keys */
const defaultProps = {
id: '',
className: '',
preTransformProps: IDENTITY,
overrideTransformProps: undefined,
postTransformProps: IDENTITY,
onRenderSuccess() {},
onRenderFailure() {},
FallbackComponent: DefaultFallbackComponent,
// eslint-disable-next-line no-magic-numbers
height: 400 as string | number,
width: '100%' as string | number,
};
/* eslint-enable sort-keys */

interface LoadingProps {
error: any;
}

interface LoadedModules {
Chart: ChartType;
transformProps: TransformProps;
}

interface RenderProps {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
postTransformProps?: PostTransformProps;
}
export type FallbackPropsWithDimension = FallbackProps & { width?: number; height?: number };

const BLANK_CHART_PROPS = new ChartProps();
export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
Omit<ChartPropsConfig, 'width' | 'height'> & {
disableErrorBoundary?: boolean;
debounceTime?: number;
FallbackComponent?: React.ComponentType<FallbackPropsWithDimension>;
onErrorBoundary?: ErrorBoundaryProps['onError'];
height?: number | string;
width?: number | string;
};

export interface SuperChartProps {
id?: string;
className?: string;
chartProps?: ChartProps | null;
chartType: string;
preTransformProps?: PreTransformProps;
overrideTransformProps?: TransformProps;
postTransformProps?: PostTransformProps;
onRenderSuccess?: HandlerFunction;
onRenderFailure?: HandlerFunction;
}
type PropsWithDefault = Props & Readonly<typeof defaultProps>;

export default class SuperChart extends React.PureComponent<SuperChartProps, {}> {
export default class SuperChart extends React.PureComponent<Props, {}> {
static defaultProps = defaultProps;

processChartProps: (input: {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
transformProps?: TransformProps;
postTransformProps?: PostTransformProps;
}) => any;

createLoadableRenderer: (input: {
chartType: string;
overrideTransformProps?: TransformProps;
}) => LoadableRenderer<RenderProps, LoadedModules> | (() => null);

constructor(props: SuperChartProps) {
super(props);

this.renderChart = this.renderChart.bind(this);
this.renderLoading = this.renderLoading.bind(this);

// memoized function so it will not recompute
// and return previous value
// unless one of
// - preTransformProps
// - transformProps
// - postTransformProps
// - chartProps
// is changed.
this.processChartProps = createSelector(
input => input.preTransformProps,
input => input.transformProps,
input => input.postTransformProps,
input => input.chartProps,
(pre = IDENTITY, transform = IDENTITY, post = IDENTITY, chartProps) =>
post(transform(pre(chartProps))),
);

const componentRegistry = getChartComponentRegistry();
const transformPropsRegistry = getChartTransformPropsRegistry();

// memoized function so it will not recompute
// and return previous value
// unless one of
// - chartType
// - overrideTransformProps
// is changed.
this.createLoadableRenderer = createSelector(
input => input.chartType,
input => input.overrideTransformProps,
(chartType, overrideTransformProps) => {
if (chartType) {
const Renderer = createLoadableRenderer({
loader: {
Chart: () => componentRegistry.getAsPromise(chartType),
transformProps: overrideTransformProps
? () => Promise.resolve(overrideTransformProps)
: () => transformPropsRegistry.getAsPromise(chartType),
},
loading: (loadingProps: LoadingProps) => this.renderLoading(loadingProps, chartType),
render: this.renderChart,
});

// Trigger preloading.
Renderer.preload();

return Renderer;
}

return EMPTY;
},
);
}

renderChart(loaded: LoadedModules, props: RenderProps) {
const { Chart, transformProps } = loaded;
const { chartProps, preTransformProps, postTransformProps } = props;
private createChartProps = ChartProps.createSelector();

return (
<Chart
{...this.processChartProps({
/* eslint-disable sort-keys */
chartProps,
preTransformProps,
transformProps,
postTransformProps,
/* eslint-enable sort-keys */
})}
/>
);
}

renderLoading(loadingProps: LoadingProps, chartType: string) {
const { error } = loadingProps;

if (error) {
return (
<div className="alert alert-warning" role="alert">
<strong>ERROR</strong>&nbsp;
<code>chartType=&quot;{chartType}&quot;</code> &mdash;
{error.toString()}
</div>
);
}

return null;
}

render() {
renderChart(width: number, height: number) {
const {
id,
className,
chartType,
preTransformProps,
overrideTransformProps,
postTransformProps,
chartProps = BLANK_CHART_PROPS,
onRenderSuccess,
onRenderFailure,
} = this.props;
disableErrorBoundary,
FallbackComponent,
onErrorBoundary,
...rest
} = this.props as PropsWithDefault;

const chart = (
<SuperChartCore
id={id}
className={className}
chartType={chartType}
chartProps={this.createChartProps({
...rest,
height,
width,
})}
preTransformProps={preTransformProps}
overrideTransformProps={overrideTransformProps}
postTransformProps={postTransformProps}
onRenderSuccess={onRenderSuccess}
onRenderFailure={onRenderFailure}
/>
);

// Create LoadableRenderer and start preloading
// the lazy-loaded Chart components
const Renderer = this.createLoadableRenderer(this.props);
// Include the error boundary by default unless it is specifically disabled.
return disableErrorBoundary === true ? (
chart
) : (
<ErrorBoundary
FallbackComponent={(props: FallbackProps) => (
<FallbackComponent width={width} height={height} {...props} />
)}
onError={onErrorBoundary}
>
{chart}
</ErrorBoundary>
);
}

// Do not render if chartProps is set to null.
// but the pre-loading has been started in this.createLoadableRenderer
// to prepare for rendering once chartProps becomes available.
if (chartProps === null) {
return null;
}
render() {
const { width: inputWidth, height: inputHeight } = this.props as PropsWithDefault;

const containerProps: {
id?: string;
className?: string;
} = {};
if (id) {
containerProps.id = id;
}
if (className) {
containerProps.className = className;
// Parse them in case they are % or 'auto'
const widthInfo = parseLength(inputWidth);
const heightInfo = parseLength(inputHeight);

// If any of the dimension is dynamic, get parent's dimension
if (widthInfo.isDynamic || heightInfo.isDynamic) {
const { debounceTime } = this.props;

return (
<ParentSize debounceTime={debounceTime}>
{({ width, height }) =>
width > 0 &&
height > 0 &&
this.renderChart(
widthInfo.isDynamic ? Math.floor(width * widthInfo.multiplier) : widthInfo.value,
heightInfo.isDynamic ? Math.floor(height * heightInfo.multiplier) : heightInfo.value,
)
}
</ParentSize>
);
}

return (
<div {...containerProps}>
<Renderer
preTransformProps={preTransformProps}
postTransformProps={postTransformProps}
chartProps={chartProps}
onRenderSuccess={onRenderSuccess}
onRenderFailure={onRenderFailure}
/>
</div>
);
return this.renderChart(widthInfo.value, heightInfo.value);
}
}
Loading

0 comments on commit c17b13d

Please sign in to comment.