This repository has been archived by the owner on Dec 10, 2021. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 271
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add error boundary and responsiveness to SuperChart (#175)
* 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
Showing
20 changed files
with
1,096 additions
and
357 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
22 changes: 22 additions & 0 deletions
22
packages/superset-ui-chart/__mocks__/resize-observer-polyfill.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
34 changes: 34 additions & 0 deletions
34
packages/superset-ui-chart/src/components/FallbackComponent.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
272
packages/superset-ui-chart/src/components/SuperChart.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
<code>chartType="{chartType}"</code> — | ||
{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); | ||
} | ||
} |
Oops, something went wrong.