Skip to content

Commit

Permalink
feat: add error boundary and responsiveness to SuperChart (apache#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 authored and zhaoyongjie committed Nov 25, 2021
1 parent 31b1b92 commit a88dda3
Show file tree
Hide file tree
Showing 20 changed files with 1,096 additions and 357 deletions.
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
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);
});
}
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"
}
}
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>
);
}
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 a88dda3

Please sign in to comment.