diff --git a/packages/superset-ui-chart-composition/README.md b/packages/superset-ui-chart-composition/README.md new file mode 100644 index 0000000000000..de9465e756718 --- /dev/null +++ b/packages/superset-ui-chart-composition/README.md @@ -0,0 +1,28 @@ +## @superset-ui/chart-composition + +[![Version](https://img.shields.io/npm/v/@superset-ui/chart-composition.svg?style=flat)](https://img.shields.io/npm/v/@superset-ui/chart-composition.svg?style=flat) +[![David (path)](https://img.shields.io/david/apache-superset/superset-ui.svg?path=packages%2Fsuperset-ui-chart-composition&style=flat-square)](https://david-dm.org/apache-superset/superset-ui?path=packages/superset-ui-chart-composition) + +Description + +#### Example usage + +```js +import { + ChartFrame, + TooltipFrame, + TooltipTable, + WithLegend, +} from '@superset-ui/chart-composition'; +``` + +#### API + +`fn(args)` + +- Do something + +### Development + +`@data-ui/build-config` is used to manage the build configuration for this package including babel +builds, jest testing, eslint, and prettier. diff --git a/packages/superset-ui-chart-composition/__mocks__/resize-observer-polyfill.js b/packages/superset-ui-chart-composition/__mocks__/resize-observer-polyfill.js new file mode 100644 index 0000000000000..7d45aeb99d357 --- /dev/null +++ b/packages/superset-ui-chart-composition/__mocks__/resize-observer-polyfill.js @@ -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); + }); +} diff --git a/packages/superset-ui-chart-composition/package.json b/packages/superset-ui-chart-composition/package.json new file mode 100644 index 0000000000000..1321f65e2b83b --- /dev/null +++ b/packages/superset-ui-chart-composition/package.json @@ -0,0 +1,36 @@ +{ + "name": "@superset-ui/chart-composition", + "version": "0.0.0", + "description": "Superset UI chart-composition", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" + }, + "keywords": ["superset"], + "author": "Superset", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/apache-superset/superset-ui/issues" + }, + "homepage": "https://github.com/apache-superset/superset-ui#readme", + "publishConfig": { + "access": "public" + }, + "private": true, + "dependencies": { + "@vx/responsive": "^0.0.184", + "@types/react": "^16.7.17", + "csstype": "^2.6.4" + }, + "peerDependencies": { + "@superset-ui/core": "^0.11.0", + "react": "^15 || ^16" + } +} diff --git a/packages/superset-ui-chart-composition/src/ChartFrame.tsx b/packages/superset-ui-chart-composition/src/ChartFrame.tsx new file mode 100644 index 0000000000000..6165ade37d8c0 --- /dev/null +++ b/packages/superset-ui-chart-composition/src/ChartFrame.tsx @@ -0,0 +1,47 @@ +import React, { PureComponent } from 'react'; +import { isDefined } from '@superset-ui/core'; + +function checkNumber(input: any): input is number { + return isDefined(input) && typeof input === 'number'; +} + +type Props = { + contentWidth?: number; + contentHeight?: number; + height: number; + renderContent: ({ height, width }: { height: number; width: number }) => React.ReactNode; + width: number; +}; + +export default class ChartFrame extends PureComponent { + static defaultProps = { + renderContent() {}, + }; + + render() { + const { contentWidth, contentHeight, width, height, renderContent } = this.props; + + const overflowX = checkNumber(contentWidth) && contentWidth > width; + const overflowY = checkNumber(contentHeight) && contentHeight > height; + + if (overflowX || overflowY) { + return ( +
+ {renderContent({ + height: Math.max(contentHeight || 0, height), + width: Math.max(contentWidth || 0, width), + })} +
+ ); + } + + return renderContent({ height, width }); + } +} diff --git a/packages/superset-ui-chart-composition/src/index.ts b/packages/superset-ui-chart-composition/src/index.ts new file mode 100644 index 0000000000000..f0338d243524d --- /dev/null +++ b/packages/superset-ui-chart-composition/src/index.ts @@ -0,0 +1,4 @@ +export { default as ChartFrame } from './ChartFrame'; +export { default as WithLegend } from './legend/WithLegend'; +export { default as TooltipFrame } from './tooltip/TooltipFrame'; +export { default as TooltipTable } from './tooltip/TooltipTable'; diff --git a/packages/superset-ui-chart-composition/src/legend/WithLegend.tsx b/packages/superset-ui-chart-composition/src/legend/WithLegend.tsx new file mode 100644 index 0000000000000..8709f15e96a98 --- /dev/null +++ b/packages/superset-ui-chart-composition/src/legend/WithLegend.tsx @@ -0,0 +1,130 @@ +/* eslint-disable sort-keys */ +import React, { CSSProperties, ReactNode, PureComponent } from 'react'; +import { ParentSize } from '@vx/responsive'; +// eslint-disable-next-line import/no-unresolved +import { FlexDirectionProperty } from 'csstype'; + +const defaultProps = { + className: '', + height: 'auto' as number | string, + width: 'auto' as number | string, + position: 'top', +}; + +type Props = { + className: string; + debounceTime?: number; + width: number | string; + height: number | string; + legendJustifyContent?: 'center' | 'flex-start' | 'flex-end'; + position: 'top' | 'left' | 'bottom' | 'right'; + renderChart: (dim: { width: number; height: number }) => ReactNode; + renderLegend?: (params: { direction: string }) => ReactNode; +} & Readonly; + +const LEGEND_STYLE_BASE: CSSProperties = { + display: 'flex', + flexGrow: 0, + flexShrink: 0, + fontSize: '0.9em', + order: -1, + paddingTop: '5px', +}; + +const CHART_STYLE_BASE: CSSProperties = { + flexBasis: 'auto', + flexGrow: 1, + flexShrink: 1, + position: 'relative', +}; + +class WithLegend extends PureComponent { + static defaultProps = defaultProps; + + getContainerDirection(): FlexDirectionProperty { + const { position } = this.props; + + if (position === 'left') { + return 'row'; + } else if (position === 'right') { + return 'row-reverse'; + } else if (position === 'bottom') { + return 'column-reverse'; + } + + return 'column'; + } + + getLegendJustifyContent() { + const { legendJustifyContent, position } = this.props; + if (legendJustifyContent) { + return legendJustifyContent; + } + + if (position === 'left' || position === 'right') { + return 'flex-start'; + } + + return 'flex-end'; + } + + render() { + const { + className, + debounceTime, + width, + height, + position, + renderChart, + renderLegend, + } = this.props; + + const isHorizontal = position === 'left' || position === 'right'; + + const style: CSSProperties = { + display: 'flex', + flexDirection: this.getContainerDirection(), + height, + width, + }; + + const chartStyle: CSSProperties = { ...CHART_STYLE_BASE }; + if (isHorizontal) { + chartStyle.width = 0; + } else { + chartStyle.height = 0; + } + + const legendDirection = isHorizontal ? 'column' : 'row'; + const legendStyle: CSSProperties = { + ...LEGEND_STYLE_BASE, + flexDirection: legendDirection, + justifyContent: this.getLegendJustifyContent(), + }; + + return ( +
+ {renderLegend && ( +
+ {renderLegend({ + // Pass flexDirection for @vx/legend to arrange legend items + direction: legendDirection, + })} +
+ )} +
+ + {(parent: { width: number; height: number }) => + parent.width > 0 && parent.height > 0 + ? // Only render when necessary + renderChart(parent) + : null + } + +
+
+ ); + } +} + +export default WithLegend; diff --git a/packages/superset-ui-chart-composition/src/tooltip/TooltipFrame.tsx b/packages/superset-ui-chart-composition/src/tooltip/TooltipFrame.tsx new file mode 100644 index 0000000000000..d44ced23e9ed4 --- /dev/null +++ b/packages/superset-ui-chart-composition/src/tooltip/TooltipFrame.tsx @@ -0,0 +1,28 @@ +import React, { PureComponent } from 'react'; + +const defaultProps = { + className: '', +}; + +type Props = { + className?: string; + children: React.ReactNode; +} & Readonly; + +const CONTAINER_STYLE = { padding: 8 }; + +class TooltipFrame extends PureComponent { + static defaultProps = defaultProps; + + render() { + const { className, children } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +export default TooltipFrame; diff --git a/packages/superset-ui-chart-composition/src/tooltip/TooltipTable.tsx b/packages/superset-ui-chart-composition/src/tooltip/TooltipTable.tsx new file mode 100644 index 0000000000000..f8ce9a27180d9 --- /dev/null +++ b/packages/superset-ui-chart-composition/src/tooltip/TooltipTable.tsx @@ -0,0 +1,44 @@ +import React, { CSSProperties, PureComponent, ReactNode } from 'react'; + +interface TooltipRowData { + key: string | number; + keyColumn?: ReactNode; + keyStyle?: CSSProperties; + valueColumn: ReactNode; + valueStyle?: CSSProperties; +} + +const defaultProps = { + className: '', + data: [] as TooltipRowData[], +}; + +type Props = { + className?: string; + data: TooltipRowData[]; +} & Readonly; + +const VALUE_CELL_STYLE: CSSProperties = { paddingLeft: 8, textAlign: 'right' }; + +export default class TooltipTable extends PureComponent { + static defaultProps = defaultProps; + + render() { + const { className, data } = this.props; + + return ( + + + {data.map(({ key, keyColumn, keyStyle, valueColumn, valueStyle }, i) => ( + + + + + ))} + +
{keyColumn || key} + {valueColumn} +
+ ); + } +} diff --git a/packages/superset-ui-chart-composition/test/ChartFrame.test.tsx b/packages/superset-ui-chart-composition/test/ChartFrame.test.tsx new file mode 100644 index 0000000000000..4e2caf68bdc85 --- /dev/null +++ b/packages/superset-ui-chart-composition/test/ChartFrame.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ChartFrame } from '../src'; + +describe('TooltipFrame', () => { + it('renders content that requires smaller space than frame', () => { + const wrapper = shallow( + ( +
+ {width}/{height} +
+ )} + />, + ); + expect(wrapper.find('div').text()).toEqual('400/400'); + }); + + it('renders content without specifying content size', () => { + const wrapper = shallow( + ( +
+ {width}/{height} +
+ )} + />, + ); + expect(wrapper.find('div').text()).toEqual('400/400'); + }); + + it('renders content that requires same size with frame', () => { + const wrapper = shallow( + ( +
+ {width}/{height} +
+ )} + />, + ); + expect(wrapper.find('div').text()).toEqual('400/400'); + }); + + it('renders content that requires space larger than frame', () => { + const wrapper = shallow( + ( +
+ {width}/{height} +
+ )} + />, + ); + expect(wrapper.find('div.chart').text()).toEqual('500/500'); + }); + + it('renders content that width is larger than frame', () => { + const wrapper = shallow( + ( +
+ {width}/{height} +
+ )} + />, + ); + expect(wrapper.find('div.chart').text()).toEqual('500/400'); + }); + + it('renders content that height is larger than frame', () => { + const wrapper = shallow( + ( +
+ {width}/{height} +
+ )} + />, + ); + expect(wrapper.find('div.chart').text()).toEqual('400/600'); + }); + + it('renders an empty frame when renderContent is not given', () => { + const wrapper = shallow(); + expect(wrapper.find('div')).toHaveLength(0); + }); +}); diff --git a/packages/superset-ui-chart-composition/test/legend/WithLegend.test.tsx b/packages/superset-ui-chart-composition/test/legend/WithLegend.test.tsx new file mode 100644 index 0000000000000..b0d234a7085f2 --- /dev/null +++ b/packages/superset-ui-chart-composition/test/legend/WithLegend.test.tsx @@ -0,0 +1,184 @@ +/* eslint-disable import/first */ +import React from 'react'; +import { mount, shallow } from 'enzyme'; + +jest.mock('resize-observer-polyfill'); +// @ts-ignore +import { triggerResizeObserver } from 'resize-observer-polyfill'; +import { WithLegend } from '../../src'; + +let renderChart = jest.fn(); +let renderLegend = jest.fn(); + +describe('WithLegend', () => { + beforeEach(() => { + renderChart = jest.fn(() =>
); + renderLegend = jest.fn(() =>
); + }); + + it('sets className', () => { + const wrapper = shallow( + , + ); + expect(wrapper.hasClass('test-class')).toEqual(true); + }); + + it('renders when renderLegend is not set', done => { + const wrapper = mount( + , + ); + + triggerResizeObserver(); + // Have to delay more than debounceTime (1ms) + setTimeout(() => { + expect(renderChart).toHaveBeenCalledTimes(1); + expect(wrapper.render().find('div.chart')).toHaveLength(1); + expect(wrapper.render().find('div.legend')).toHaveLength(0); + done(); + }, 100); + }); + + it('renders', done => { + const wrapper = mount( + , + ); + + triggerResizeObserver(); + // Have to delay more than debounceTime (1ms) + setTimeout(() => { + expect(renderChart).toHaveBeenCalledTimes(1); + expect(renderLegend).toHaveBeenCalledTimes(1); + expect(wrapper.render().find('div.chart')).toHaveLength(1); + expect(wrapper.render().find('div.legend')).toHaveLength(1); + done(); + }, 100); + }); + + it('renders without width or height', done => { + const wrapper = mount( + , + ); + + triggerResizeObserver(); + // Have to delay more than debounceTime (1ms) + setTimeout(() => { + expect(renderChart).toHaveBeenCalledTimes(1); + expect(renderLegend).toHaveBeenCalledTimes(1); + expect(wrapper.render().find('div.chart')).toHaveLength(1); + expect(wrapper.render().find('div.legend')).toHaveLength(1); + done(); + }, 100); + }); + + it('renders legend on the left', done => { + const wrapper = mount( + , + ); + + triggerResizeObserver(); + // Have to delay more than debounceTime (1ms) + setTimeout(() => { + expect(renderChart).toHaveBeenCalledTimes(1); + expect(renderLegend).toHaveBeenCalledTimes(1); + expect(wrapper.render().find('div.chart')).toHaveLength(1); + expect(wrapper.render().find('div.legend')).toHaveLength(1); + done(); + }, 100); + }); + + it('renders legend on the right', done => { + const wrapper = mount( + , + ); + + triggerResizeObserver(); + // Have to delay more than debounceTime (1ms) + setTimeout(() => { + expect(renderChart).toHaveBeenCalledTimes(1); + expect(renderLegend).toHaveBeenCalledTimes(1); + expect(wrapper.render().find('div.chart')).toHaveLength(1); + expect(wrapper.render().find('div.legend')).toHaveLength(1); + done(); + }, 100); + }); + + it('renders legend on the top', done => { + const wrapper = mount( + , + ); + + triggerResizeObserver(); + // Have to delay more than debounceTime (1ms) + setTimeout(() => { + expect(renderChart).toHaveBeenCalledTimes(1); + expect(renderLegend).toHaveBeenCalledTimes(1); + expect(wrapper.render().find('div.chart')).toHaveLength(1); + expect(wrapper.render().find('div.legend')).toHaveLength(1); + done(); + }, 100); + }); + + it('renders legend on the bottom', done => { + const wrapper = mount( + , + ); + + triggerResizeObserver(); + // Have to delay more than debounceTime (1ms) + setTimeout(() => { + expect(renderChart).toHaveBeenCalledTimes(1); + expect(renderLegend).toHaveBeenCalledTimes(1); + expect(wrapper.render().find('div.chart')).toHaveLength(1); + expect(wrapper.render().find('div.legend')).toHaveLength(1); + done(); + }, 100); + }); + + it('renders legend with justifyContent set', done => { + const wrapper = mount( + , + ); + + triggerResizeObserver(); + // Have to delay more than debounceTime (1ms) + setTimeout(() => { + expect(renderChart).toHaveBeenCalledTimes(1); + expect(renderLegend).toHaveBeenCalledTimes(1); + expect(wrapper.render().find('div.chart')).toHaveLength(1); + expect(wrapper.render().find('div.legend')).toHaveLength(1); + done(); + }, 100); + }); +}); diff --git a/packages/superset-ui-chart-composition/test/tooltip/TooltipFrame.test.tsx b/packages/superset-ui-chart-composition/test/tooltip/TooltipFrame.test.tsx new file mode 100644 index 0000000000000..cffdde6df6a4f --- /dev/null +++ b/packages/superset-ui-chart-composition/test/tooltip/TooltipFrame.test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { TooltipFrame } from '../../src'; + +describe('TooltipFrame', () => { + it('sets className', () => { + const wrapper = shallow( + + Hi! + , + ); + expect(wrapper.hasClass('test-class')).toEqual(true); + }); + + it('renders', () => { + const wrapper = shallow( + + Hi! + , + ); + const span = wrapper.find('span'); + expect(span).toHaveLength(1); + expect(span.text()).toEqual('Hi!'); + }); +}); diff --git a/packages/superset-ui-chart-composition/test/tooltip/TooltipTable.test.tsx b/packages/superset-ui-chart-composition/test/tooltip/TooltipTable.test.tsx new file mode 100644 index 0000000000000..8ac78597db17f --- /dev/null +++ b/packages/superset-ui-chart-composition/test/tooltip/TooltipTable.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { TooltipTable } from '../../src'; + +describe('TooltipTable', () => { + it('sets className', () => { + const wrapper = shallow(); + expect(wrapper.render().hasClass('test-class')).toEqual(true); + }); + + it('renders empty table', () => { + const wrapper = shallow(); + expect(wrapper.find('tbody')).toHaveLength(1); + expect(wrapper.find('tr')).toHaveLength(0); + }); + + it('renders table with content', () => { + const wrapper = shallow( + , + ); + expect(wrapper.find('tbody')).toHaveLength(1); + expect(wrapper.find('tr')).toHaveLength(3); + expect( + wrapper + .find('tr > td') + .first() + .text(), + ).toEqual('Cersei'); + }); +}); diff --git a/packages/superset-ui-chart-composition/types/@vx/responsive/index.d.ts b/packages/superset-ui-chart-composition/types/@vx/responsive/index.d.ts new file mode 100644 index 0000000000000..4ae01736e4e99 --- /dev/null +++ b/packages/superset-ui-chart-composition/types/@vx/responsive/index.d.ts @@ -0,0 +1,11 @@ +declare module '@vx/responsive' { + import React from 'react'; + + interface ParentSizeProps { + debounceTime?: number; + children: (renderProps: { width: number; height: number }) => React.ReactNode; + } + + // eslint-disable-next-line import/prefer-default-export + export const ParentSize: React.ComponentType; +}