From b5854678d0cdfe70c9397cdb76f545f0e56c8f94 Mon Sep 17 00:00:00 2001 From: Chris Williams Date: Tue, 19 Mar 2019 14:58:20 -0700 Subject: [PATCH] feat(chart): Add `` (#120) * docs: [demo][connection] add ConfigureCORS story for testing CORS * docs: [demo][ConfigureCORS] better instructions * feat: [chart] add mvp DataProvider component * docs: better CORS story, update webpack for @babel/polyfill * docs: [chart] add DataProvider story with WordCloudPlugin * docs: [chart] add DataProvider deets to Readme * test(chart): move SuperChart.test.jsx => .tsx and instead use @ts-ignore * fix(connection): point interface.request to client.request * feat(chart): re-write DataProvider as ChartDataProvider * docs(demo): re-write LegacyWordCloudStories => ChartDataProviderStories * refactor(chart): use IDENTITY as ChartPlugin buildQuery default * feat(chart): support legacy + v1 loadQueryData endpoints in ChartClient * docs(demo): add sankey + sunburst plugins to ChartDataProvider story * style(chart): run prettier on SuperChart * feat(chart): export QueryData type from models/ChartProps * feat(chart): export Metrics and BaseFormData from types/ChartFormData * feat(chart): add request option overrides in ChartDataProvider * fix(chart): use Partial<> for ChartClient request option overrides * test(chart): add ChartDataProvider tests * build: include demo pkg in type script * build: move storybook/mocks to test/fixtures * build: move json-bigint TS declaration to root * test(chart): clean up ChartDataProvider test TS * chore(chart): lint fix SuperChart * fix(chart): set ChartPlugin.buildQuery default back to undefined * test(connection): fix expected Client.get call count * test(chart): fix ChartClient tests and add test for legacy API * fix(chart): uninitialized typo, change fetching => loading * docs(chart): update README to final ChartDataProvider API * docs(chart): fix typo * test(chart): get ChartDataProvider to one hundo * feat(chart): add and export more meaningful Datasource type * feat(chart): use Datasource type in ChartClient --- .../superset-ui/package.json | 2 +- .../packages/superset-ui-chart/README.md | 83 +++++- .../src/clients/ChartClient.ts | 49 ++-- .../src/components/ChartDataProvider.tsx | 144 ++++++++++ .../packages/superset-ui-chart/src/index.ts | 2 + .../src/models/ChartProps.ts | 2 +- .../src/types/ChartFormData.ts | 4 +- .../superset-ui-chart/src/types/Datasource.ts | 13 +- .../test/clients/ChartClient.test.ts | 34 +++ .../components/ChartDataProvider.test.tsx | 267 ++++++++++++++++++ .../test/components/SuperChart.test.jsx | 13 - .../test/components/SuperChart.test.tsx | 8 + .../test/fixtures/formData.ts} | 29 ++ .../test/models/ChartPlugin.test.ts | 2 +- .../src/SupersetClient.ts | 2 +- .../test/SupersetClient.test.ts | 2 +- .../packages/superset-ui-demo/package.json | 6 + .../storybook/stories/index.js | 2 +- .../ChartDataProviderStories.tsx | 103 +++++++ .../stories/superset-ui-chart/index.ts | 5 + .../ConnectionStories.jsx | 2 +- .../types/external.d.ts | 0 22 files changed, 726 insertions(+), 48 deletions(-) create mode 100644 superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/components/ChartDataProvider.tsx create mode 100644 superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/ChartDataProvider.test.tsx delete mode 100644 superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/SuperChart.test.jsx rename superset-frontend/temporary_superset_ui/superset-ui/packages/{superset-ui-demo/storybook/stories/mocks/formData.js => superset-ui-chart/test/fixtures/formData.ts} (56%) create mode 100644 superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-chart/ChartDataProviderStories.tsx create mode 100644 superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-chart/index.ts rename superset-frontend/temporary_superset_ui/superset-ui/{packages/superset-ui-connection => }/types/external.d.ts (100%) diff --git a/superset-frontend/temporary_superset_ui/superset-ui/package.json b/superset-frontend/temporary_superset_ui/superset-ui/package.json index d611455a46d90..4050654040aa2 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/package.json +++ b/superset-frontend/temporary_superset_ui/superset-ui/package.json @@ -10,7 +10,7 @@ "build:esm": "NODE_ENV=production beemo babel --extensions=\".js,.jsx,.ts,.tsx\" ./src --out-dir esm/ --esm --minify --workspaces=\"@superset-ui/!(demo|generator-superset)\"", "build:assets": "node ./scripts/buildAssets.js", "commit": "git-cz", - "type": "NODE_ENV=production beemo typescript --workspaces=\"@superset-ui/!(demo|generator-superset)\" --noEmit", + "type": "NODE_ENV=production beemo typescript --workspaces=\"@superset-ui/!(generator-superset)\" --noEmit", "type:dts": "NODE_ENV=production beemo typescript --workspaces=\"@superset-ui/!(demo|generator-superset)\" --emitDeclarationOnly", "lint": "beemo create-config prettier && beemo eslint \"./packages/*/{src,test,storybook}/**/*.{js,jsx,ts,tsx}\"", "lint:fix": "beemo create-config prettier && beemo eslint --fix \"./packages/*/{src,test,storybook}/**/*.{js,jsx,ts,tsx}\"", diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/README.md b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/README.md index 2d78601083b5e..c64a92c78944e 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/README.md +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/README.md @@ -7,15 +7,86 @@ Description #### Example usage -```js -import { xxx } from '@superset-ui/chart'; -``` +##### `` + +This component is a React utility wrapper around the `@superset-ui/chart` `ChartClient` and will +generally require you to setup `CORS` (CROSS ORIGIN RESOURCE SHARING) to accept cross-origin +requests from domains outside your `Apache Superset` instance: + +1. Configure `CORS` in your `Apache Superset` instance. + + a. Enable `CORS` requests to (minimally) the resources defined below. + + b. Enable `CORS` requests from the relevant domains (i.e., the app in which you will embed + charts) + + ```python + # config.py + ENABLE_CORS = True + CORS_OPTIONS = { + 'supports_credentials': True, + 'allow_headers': [ + 'X-CSRFToken', 'Content-Type', 'Origin', 'X-Requested-With', 'Accept', + ], + 'resources': [ + '/superset/csrf_token/' # auth + '/api/v1/formData/', # sliceId => formData + '/superset/explore_json/*', # legacy query API, formData => queryData + '/api/v1/query/', # new query API, queryContext => queryData + '/superset/fetch_datasource_metadata/' # datasource metadata + + ], + 'origins': ['http://myappdomain:9001'], + } + ``` + +2. Configure `SupersetClient` in the app where you will embed your charts. You can test this + configuration in the `@superset-ui` storybook. + + ```javascript + import { SupersetClient } from '@superset-ui/connection'; -#### API + SupersetClient.configure({ + credentials: 'include', + host: `${SUPERSET_APP_HOST}`, + mode: 'cors', + }).init(); + ``` + +3. Register any desired / needed `@superset-ui` chart + color plugins. + + ```javascript + import WordCloudPlugin from '@superset-ui/plugin-chart-word-cloud'; + + new WordCloudPlugin().configure({ key: 'word_cloud' }).register(); + ``` + +4. Pass `SupersetClient` to the `ChartDataProvider` along with the formData for the desired + visualization type. + +```javascript +import { ChartDataProvider } from '@superset-ui/chart'; + +const render = () => ( + + {({ loading, error, payload }) => ( + <> + {loading && } + + {error && } + + {payload && ( + + )} + + )} + +); +``` -`fn(args)` +##### `` -- Do something +Coming soon. ### Development diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/clients/ChartClient.ts b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/clients/ChartClient.ts index d23ef9f48a246..6297a057ee16f 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/clients/ChartClient.ts +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/clients/ChartClient.ts @@ -7,27 +7,29 @@ import { SupersetClientClass, } from '@superset-ui/connection'; import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton'; +import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton'; import { AnnotationLayerMetadata } from '../types/Annotation'; import { ChartFormData } from '../types/ChartFormData'; +import { QueryData } from '../models/ChartProps'; +import { Datasource } from '../types/Datasource'; -export type SliceIdAndOrFormData = - | { - sliceId: number; - formData?: Partial; - } - | { - formData: ChartFormData; - }; +// This expands to Partial & (union of all possible single-property types) +type AtLeastOne }> = Partial & Each[keyof Each]; + +export type SliceIdAndOrFormData = AtLeastOne<{ + sliceId: number; + formData: Partial; +}>; interface AnnotationData { [key: string]: object; } -interface ChartData { +export interface ChartData { annotationData: AnnotationData; datasource: object; formData: ChartFormData; - queryData: object; + queryData: QueryData; } export default class ChartClient { @@ -42,7 +44,10 @@ export default class ChartClient { this.client = client; } - loadFormData(input: SliceIdAndOrFormData, options?: RequestConfig): Promise { + loadFormData( + input: SliceIdAndOrFormData, + options?: Partial, + ): Promise { /* If sliceId is provided, use it to fetch stored formData from API */ if ('sliceId' in input) { const promise = this.client @@ -69,28 +74,34 @@ export default class ChartClient { : Promise.reject(new Error('At least one of sliceId or formData must be specified')); } - loadQueryData(formData: ChartFormData, options?: RequestConfig): Promise { - const buildQuery = getChartBuildQueryRegistry().get(formData.viz_type); - if (buildQuery) { + loadQueryData(formData: ChartFormData, options?: Partial): Promise { + const { viz_type: visType } = formData; + + if (getChartMetadataRegistry().has(visType)) { + const { useLegacyApi } = getChartMetadataRegistry().get(visType); + const buildQuery = useLegacyApi ? () => formData : getChartBuildQueryRegistry().get(visType); + return this.client .post({ - endpoint: '/api/v1/query/', - postPayload: { query_context: buildQuery(formData) }, + endpoint: useLegacyApi ? '/superset/explore_json/' : '/api/v1/query/', + postPayload: { + [useLegacyApi ? 'form_data' : 'query_context']: buildQuery(formData), + }, ...options, } as RequestConfig) .then(response => response.json as Json); } - return Promise.reject(new Error(`Unknown chart type: ${formData.viz_type}`)); + return Promise.reject(new Error(`Unknown chart type: ${visType}`)); } - loadDatasource(datasourceKey: string, options?: RequestConfig): Promise { + loadDatasource(datasourceKey: string, options?: Partial): Promise { return this.client .get({ endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`, ...options, } as RequestConfig) - .then(response => response.json as Json); + .then(response => response.json as Datasource); } loadAnnotation(annotationLayer: AnnotationLayerMetadata): Promise { diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/components/ChartDataProvider.tsx b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/components/ChartDataProvider.tsx new file mode 100644 index 0000000000000..7d91b23907dc1 --- /dev/null +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/components/ChartDataProvider.tsx @@ -0,0 +1,144 @@ +/* eslint react/sort-comp: 'off' */ +import React, { ReactNode } from 'react'; +import { SupersetClientInterface, RequestConfig } from '../../../superset-ui-connection/src/types'; + +import ChartClient, { SliceIdAndOrFormData } from '../clients/ChartClient'; +import { ChartFormData } from '../types/ChartFormData'; +import { Datasource } from '../types/Datasource'; +import { QueryData } from '../models/ChartProps'; + +interface Payload { + formData: Partial; + queryData: QueryData; + datasource?: Datasource; +} + +export interface ProvidedProps { + payload?: Payload; + error?: Error; + loading?: boolean; +} + +export type Props = + /** User can pass either one or both of sliceId or formData */ + SliceIdAndOrFormData & { + /** Child function called with ProvidedProps */ + children: (provided: ProvidedProps) => ReactNode; + /** Superset client which is used to fetch data. It should already be configured and initialized. */ + client?: SupersetClientInterface; + /** Will fetch and include datasource metadata for SliceIdAndOrFormData in the payload. */ + loadDatasource?: boolean; + /** Callback when an error occurs. Enables wrapping the Provider in an ErrorBoundary. */ + onError?: (error: ProvidedProps['error']) => void; + /** Callback when data is loaded. */ + onLoaded?: (payload: ProvidedProps['payload']) => void; + /** Hook to override the formData request config. */ + formDataRequestOptions?: Partial; + /** Hook to override the datasource request config. */ + datasourceRequestOptions?: Partial; + /** Hook to override the queryData request config. */ + queryRequestOptions?: Partial; + }; + +type State = { + status: 'uninitialized' | 'loading' | 'error' | 'loaded'; + payload?: ProvidedProps['payload']; + error?: ProvidedProps['error']; +}; + +class ChartDataProvider extends React.PureComponent { + readonly chartClient: ChartClient; + + constructor(props: Props) { + super(props); + this.handleFetchData = this.handleFetchData.bind(this); + this.handleReceiveData = this.handleReceiveData.bind(this); + this.handleError = this.handleError.bind(this); + this.chartClient = new ChartClient({ client: props.client }); + this.state = { status: 'uninitialized' }; + } + + componentDidMount() { + this.handleFetchData(); + } + + componentDidUpdate(prevProps: Props) { + const { formData, sliceId } = this.props; + if (formData !== prevProps.formData || sliceId !== prevProps.sliceId) { + this.handleFetchData(); + } + } + + private extractSliceIdAndFormData() { + const { formData, sliceId } = this.props; + const result: any = {}; + + if (formData) result.formData = formData; + if (sliceId) result.sliceId = sliceId; + + return result as SliceIdAndOrFormData; + } + + private handleFetchData() { + const { + loadDatasource, + formDataRequestOptions, + datasourceRequestOptions, + queryRequestOptions, + } = this.props; + + this.setState({ status: 'loading' }, () => { + try { + this.chartClient + .loadFormData(this.extractSliceIdAndFormData(), formDataRequestOptions) + .then(formData => + Promise.all([ + loadDatasource + ? this.chartClient.loadDatasource(formData.datasource, datasourceRequestOptions) + : Promise.resolve(undefined), + this.chartClient.loadQueryData(formData, queryRequestOptions), + ]).then(([datasource, queryData]) => ({ + datasource, + formData, + queryData, + })), + ) + .then(this.handleReceiveData) + .catch(this.handleError); + } catch (error) { + this.handleError(error); + } + }); + } + + handleReceiveData(data: Payload) { + const { onLoaded } = this.props; + if (onLoaded) onLoaded(data); + this.setState({ payload: data, status: 'loaded' }); + } + + handleError(error: ProvidedProps['error']) { + const { onError } = this.props; + if (onError) onError(error); + this.setState({ error, status: 'error' }); + } + + render() { + const { children } = this.props; + const { status, payload, error } = this.state; + + switch (status) { + case 'loading': + return children({ loading: true }); + case 'loaded': + return children({ payload }); + case 'error': + return children({ error }); + case 'uninitialized': + default: + return null; + } + } +} + +export default ChartDataProvider; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/index.ts b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/index.ts index 6fc67bfed5e54..eb4d379b528fe 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/index.ts +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/index.ts @@ -19,6 +19,8 @@ export { export { default as buildQueryContext } from './query/buildQueryContext'; export { default as DatasourceKey } from './query/DatasourceKey'; +export { default as ChartDataProvider } from './components/ChartDataProvider'; + export * from './types/Annotation'; export * from './types/Datasource'; export * from './types/ChartFormData'; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/models/ChartProps.ts b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/models/ChartProps.ts index 6d60a1dfa9f77..bb079ba0e48e2 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/models/ChartProps.ts +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/models/ChartProps.ts @@ -11,7 +11,7 @@ type CamelCaseDatasource = PlainObject; type SnakeCaseDatasource = PlainObject; type CamelCaseFormData = PlainObject; type SnakeCaseFormData = PlainObject; -type QueryData = PlainObject; +export type QueryData = PlainObject; type Filters = any[]; type HandlerFunction = (...args: any[]) => void; type ChartPropsSelector = (c: ChartPropsConfig) => ChartProps; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/types/ChartFormData.ts b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/types/ChartFormData.ts index a9fdfb0697a75..59877bdc125a3 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/types/ChartFormData.ts +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/types/ChartFormData.ts @@ -11,9 +11,9 @@ import { AnnotationLayerMetadata } from './Annotation'; // https://github.com/Microsoft/TypeScript/issues/13573 // The Metrics in formData is either a string or a proper metric. It will be // unified into a proper Metric type during buildQuery (see `/query/Metrics.ts`). -type Metrics = Partial>; +export type Metrics = Partial>; -type BaseFormData = { +export type BaseFormData = { datasource: string; viz_type: string; annotation_layers?: AnnotationLayerMetadata[]; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/types/Datasource.ts b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/types/Datasource.ts index a0a0d6c3aa6d2..480dac5a3d31a 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/types/Datasource.ts +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/src/types/Datasource.ts @@ -1,6 +1,17 @@ -/* eslint-disable import/prefer-default-export, no-unused-vars */ +import { Column } from './Column'; +import { Metric } from './Metric'; export enum DatasourceType { Table = 'table', Druid = 'druid', } + +/** @TODO can continue to add fields to this */ +export interface Datasource { + id: number; + name: string; + description?: string; + type: DatasourceType; + columns: Column[]; + metrics: Metric[]; +} diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/clients/ChartClient.test.ts b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/clients/ChartClient.test.ts index b877023c0baa1..c3db3b43047b3 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/clients/ChartClient.test.ts +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/clients/ChartClient.test.ts @@ -6,7 +6,9 @@ import { getChartBuildQueryRegistry, buildQueryContext, ChartFormData, + getChartMetadataRegistry, } from '../../src'; + import { SliceIdAndOrFormData } from '../../src/clients/ChartClient'; import { LOGIN_GLOB } from '../../../superset-ui-connection/test/fixtures/constants'; @@ -95,6 +97,8 @@ describe('ChartClient', () => { describe('.loadQueryData(formData, options)', () => { it('returns a promise of query data for known chart type', () => { + getChartMetadataRegistry().registerValue('word_cloud', { name: 'Word Cloud' }); + getChartBuildQueryRegistry().registerValue('word_cloud', (formData: ChartFormData) => buildQueryContext(formData), ); @@ -122,6 +126,34 @@ describe('ChartClient', () => { datasource: '1__table', }), ).rejects.toEqual(new Error('Unknown chart type: rainbow_3d_pie'))); + + it('fetches data from the legacy API if ChartMetadata has useLegacyApi=true,', () => { + // note legacy charts do not register a buildQuery function in the registry + getChartMetadataRegistry().registerValue('word_cloud_legacy', { + name: 'Legacy Word Cloud', + useLegacyApi: true, + }); + + fetchMock.post('glob:*/api/v1/query/', () => + Promise.reject(Error('Unexpected all to v1 API')), + ); + + fetchMock.post('glob:*/superset/explore_json/', { + field1: 'abc', + field2: 'def', + }); + + return expect( + chartClient.loadQueryData({ + granularity: 'minute', + viz_type: 'word_cloud_legacy', + datasource: '1__table', + }), + ).resolves.toEqual({ + field1: 'abc', + field2: 'def', + }); + }); }); describe('.loadDatasource(datasourceKey, options)', () => { @@ -201,6 +233,8 @@ describe('ChartClient', () => { amet: true, }); + getChartMetadataRegistry().registerValue('line', { name: 'Line' }); + getChartBuildQueryRegistry().registerValue('line', (formData: ChartFormData) => buildQueryContext(formData), ); diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/ChartDataProvider.test.tsx b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/ChartDataProvider.test.tsx new file mode 100644 index 0000000000000..2ddce5f02430b --- /dev/null +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/ChartDataProvider.test.tsx @@ -0,0 +1,267 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ChartClient from '../../src/clients/ChartClient'; +import ChartDataProvider, { Props } from '../../src/components/ChartDataProvider'; +import { bigNumberFormData } from '../fixtures/formData'; + +// Note: the mock implentatino of these function directly affects the expected results below +const defaultMockLoadFormData = jest.fn(allProps => Promise.resolve(allProps.formData)); + +// coerce here else get: Type 'Mock, []>' is not assignable to type 'Mock, any[]>' +let mockLoadFormData = defaultMockLoadFormData as jest.Mock, any>; +const mockLoadDatasource = jest.fn(datasource => Promise.resolve(datasource)); +const mockLoadQueryData = jest.fn(input => Promise.resolve(input)); + +// ChartClient is now a mock +jest.mock('../../src/clients/ChartClient', () => + jest.fn().mockImplementation(() => ({ + loadDatasource: mockLoadDatasource, + loadFormData: mockLoadFormData, + loadQueryData: mockLoadQueryData, + })), +); + +const ChartClientMock = ChartClient as jest.Mock; + +describe('ChartDataProvider', () => { + beforeEach(() => { + ChartClientMock.mockClear(); + + mockLoadFormData = defaultMockLoadFormData; + mockLoadFormData.mockClear(); + mockLoadDatasource.mockClear(); + mockLoadQueryData.mockClear(); + }); + + const props: Props = { + formData: { ...bigNumberFormData }, + children: () =>
, + }; + + function setup(overrideProps?: Partial) { + return shallow(); + } + + it('instantiates a new ChartClient()', () => { + setup(); + expect(ChartClientMock).toHaveBeenCalledTimes(1); + }); + + describe('ChartClient.loadFormData', () => { + it('calls method on mount', () => { + setup(); + expect(mockLoadFormData.mock.calls).toHaveLength(1); + expect(mockLoadFormData.mock.calls[0][0]).toEqual({ + sliceId: props.sliceId, + formData: props.formData, + }); + }); + + it('should pass formDataRequestOptions to ChartClient.loadFormData', () => { + const options = { host: 'override' }; + setup({ formDataRequestOptions: options }); + expect(mockLoadFormData.mock.calls).toHaveLength(1); + expect(mockLoadFormData.mock.calls[0][1]).toEqual(options); + }); + + it('calls ChartClient.loadFormData when formData or sliceId change', () => { + const wrapper = setup(); + const newProps = { sliceId: 123, formData: undefined }; + expect(mockLoadFormData.mock.calls).toHaveLength(1); + + wrapper.setProps(newProps); + expect(mockLoadFormData.mock.calls).toHaveLength(2); + expect(mockLoadFormData.mock.calls[1][0]).toEqual(newProps); + }); + }); + + describe('ChartClient.loadDatasource', () => { + it('does not method if loadDatasource is false', done => { + expect.assertions(1); + setup({ loadDatasource: false }); + setTimeout(() => { + expect(mockLoadDatasource.mock.calls).toHaveLength(0); + done(); + }, 0); + }); + + it('calls method on mount if loadDatasource is true', done => { + expect.assertions(2); + setup({ loadDatasource: true }); + setTimeout(() => { + expect(mockLoadDatasource.mock.calls).toHaveLength(1); + expect(mockLoadDatasource.mock.calls[0][0]).toEqual(props.formData!.datasource); + done(); + }, 0); + }); + + it('should pass datasourceRequestOptions to ChartClient.loadDatasource', done => { + expect.assertions(2); + const options = { host: 'override' }; + setup({ loadDatasource: true, datasourceRequestOptions: options }); + setTimeout(() => { + expect(mockLoadDatasource.mock.calls).toHaveLength(1); + expect(mockLoadDatasource.mock.calls[0][1]).toEqual(options); + done(); + }, 0); + }); + + it('calls ChartClient.loadDatasource if loadDatasource is true and formData or sliceId change', done => { + expect.assertions(3); + const newDatasource = 'test'; + const wrapper = setup({ loadDatasource: true }); + wrapper.setProps({ formData: { datasource: newDatasource }, sliceId: undefined }); + + setTimeout(() => { + expect(mockLoadDatasource.mock.calls).toHaveLength(2); + expect(mockLoadDatasource.mock.calls[0][0]).toEqual(props.formData!.datasource); + expect(mockLoadDatasource.mock.calls[1][0]).toEqual(newDatasource); + done(); + }, 0); + }); + }); + + describe('ChartClient.loadQueryData', () => { + it('calls method on mount', done => { + expect.assertions(2); + setup(); + setTimeout(() => { + expect(mockLoadQueryData.mock.calls).toHaveLength(1); + expect(mockLoadQueryData.mock.calls[0][0]).toEqual(props.formData); + done(); + }, 0); + }); + + it('should pass queryDataRequestOptions to ChartClient.loadQueryData', done => { + expect.assertions(2); + const options = { host: 'override' }; + setup({ queryRequestOptions: options }); + setTimeout(() => { + expect(mockLoadQueryData.mock.calls).toHaveLength(1); + expect(mockLoadQueryData.mock.calls[0][1]).toEqual(options); + done(); + }, 0); + }); + + it('calls ChartClient.loadQueryData when formData or sliceId change', done => { + expect.assertions(3); + const newFormData = { key: 'test' }; + const wrapper = setup(); + wrapper.setProps({ formData: newFormData, sliceId: undefined }); + + setTimeout(() => { + expect(mockLoadQueryData.mock.calls).toHaveLength(2); + expect(mockLoadQueryData.mock.calls[0][0]).toEqual(props.formData); + expect(mockLoadQueryData.mock.calls[1][0]).toEqual(newFormData); + done(); + }, 0); + }); + }); + + describe('children', () => { + it('calls children({ loading: true }) when loading', () => { + const children = jest.fn(); + setup({ children }); + + // during the first tick (before more promises resolve) loading is true + expect(children.mock.calls).toHaveLength(1); + expect(children.mock.calls[0][0]).toEqual({ loading: true }); + }); + + it('calls children({ payload }) when loaded', done => { + expect.assertions(2); + const children = jest.fn(); + setup({ children, loadDatasource: true }); + + setTimeout(() => { + expect(children.mock.calls).toHaveLength(2); + expect(children.mock.calls[1][0]).toEqual({ + payload: { + formData: props.formData, + datasource: props.formData!.datasource, + queryData: props.formData, + }, + }); + done(); + }, 0); + }); + + it('calls children({ error }) upon request error', done => { + expect.assertions(2); + const children = jest.fn(); + mockLoadFormData = jest.fn(() => Promise.reject(Error('error'))); + + setup({ children }); + + setTimeout(() => { + expect(children.mock.calls).toHaveLength(2); // loading + error + expect(children.mock.calls[1][0]).toEqual({ error: Error('error') }); + done(); + }, 0); + }); + + it('calls children({ error }) upon JS error', done => { + expect.assertions(2); + const children = jest.fn(); + + mockLoadFormData = jest.fn(() => { + throw new Error('non-async error'); + }); + + setup({ children }); + + setTimeout(() => { + expect(children.mock.calls).toHaveLength(2); // loading + error + expect(children.mock.calls[1][0]).toEqual({ error: Error('non-async error') }); + done(); + }, 0); + }); + }); + + describe('callbacks', () => { + it('calls onLoad(payload) when loaded', done => { + expect.assertions(2); + const onLoaded = jest.fn(); + setup({ onLoaded, loadDatasource: true }); + + setTimeout(() => { + expect(onLoaded.mock.calls).toHaveLength(1); + expect(onLoaded.mock.calls[0][0]).toEqual({ + formData: props.formData, + datasource: props.formData!.datasource, + queryData: props.formData, + }); + done(); + }, 0); + }); + + it('calls onError(error) upon request error', done => { + expect.assertions(2); + const onError = jest.fn(); + mockLoadFormData = jest.fn(() => Promise.reject(Error('error'))); + + setup({ onError }); + setTimeout(() => { + expect(onError.mock.calls).toHaveLength(1); + expect(onError.mock.calls[0][0]).toEqual(Error('error')); + done(); + }, 0); + }); + + it('calls onError(error) upon JS error', done => { + expect.assertions(2); + const onError = jest.fn(); + + mockLoadFormData = jest.fn(() => { + throw new Error('non-async error'); + }); + + setup({ onError }); + setTimeout(() => { + expect(onError.mock.calls).toHaveLength(1); + expect(onError.mock.calls[0][0]).toEqual(Error('non-async error')); + done(); + }, 0); + }); + }); +}); diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/SuperChart.test.jsx b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/SuperChart.test.jsx deleted file mode 100644 index 5a835a36ddad0..0000000000000 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/SuperChart.test.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import { SuperChart } from '../../src'; - -describe('SuperChart', () => { - it('does not render if chartType is not set', done => { - const wrapper = shallow(); - setTimeout(() => { - expect(wrapper.render().children()).toHaveLength(0); - done(); - }, 5); - }); -}); diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/SuperChart.test.tsx b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/SuperChart.test.tsx index 8af0796e17bd9..9ac1a25dd6de5 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/SuperChart.test.tsx +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/components/SuperChart.test.tsx @@ -71,6 +71,14 @@ describe('SuperChart', () => { done(); }, 0); }); + it('does not render if chartType is not set', done => { + // @ts-ignore chartType is required + const wrapper = shallow(); + setTimeout(() => { + expect(wrapper.render().children()).toHaveLength(0); + done(); + }, 5); + }); it('adds id to container if specified', done => { const wrapper = shallow(); setTimeout(() => { diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/mocks/formData.js b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/fixtures/formData.ts similarity index 56% rename from superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/mocks/formData.js rename to superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/fixtures/formData.ts index 59070cdf78868..d7b7f8bf32196 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/mocks/formData.js +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/fixtures/formData.ts @@ -33,3 +33,32 @@ export const wordCloudFormData = { size_to: 70, rotation: 'square', }; + +export const sunburstFormData = { + datasource: '2__table', + viz_type: 'sunburst', + slice_id: 47, + url_params: {}, + granularity_sqla: 'year', + time_grain_sqla: 'P1D', + time_range: '2011-01-01 : 2011-01-01', + groupby: ['region', 'country_name'], + metric: 'sum__SP_POP_TOTL', + secondary_metric: 'sum__SP_RUR_TOTL', + adhoc_filters: [], + row_limit: 10000, +}; + +export const sankeyFormData = { + datasource: '1__table', + viz_type: 'sankey', + slice_id: 1, + url_params: {}, + granularity_sqla: null, + time_grain_sqla: 'P1D', + time_range: 'Last week', + groupby: ['source', 'target'], + metric: 'sum__value', + adhoc_filters: [], + row_limit: 1000, +}; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/models/ChartPlugin.test.ts b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/models/ChartPlugin.test.ts index ecc5743ee98a8..04af31099a15d 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/models/ChartPlugin.test.ts +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-chart/test/models/ChartPlugin.test.ts @@ -38,7 +38,7 @@ describe('ChartPlugin', () => { expect(plugin).toBeInstanceOf(ChartPlugin); }); describe('buildQuery', () => { - it('defaults to identity function', () => { + it('defaults to undefined', () => { const plugin = new ChartPlugin({ metadata, Chart: FakeChart, diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-connection/src/SupersetClient.ts b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-connection/src/SupersetClient.ts index 283c2729b07b8..73b642998d533 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-connection/src/SupersetClient.ts +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-connection/src/SupersetClient.ts @@ -25,7 +25,7 @@ const SupersetClient: SupersetClientInterface = { post: (request: RequestConfig) => getInstance(singletonClient).post(request), put: (request: RequestConfig) => getInstance(singletonClient).put(request), reAuthenticate: () => getInstance(singletonClient).init(/* force = */ true), - request: (request: RequestConfig) => getInstance(singletonClient).get(request), + request: (request: RequestConfig) => getInstance(singletonClient).request(request), reset: () => { singletonClient = undefined; }, diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-connection/test/SupersetClient.test.ts b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-connection/test/SupersetClient.test.ts index 13030d0934cda..a4148dd762429 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-connection/test/SupersetClient.test.ts +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-connection/test/SupersetClient.test.ts @@ -73,7 +73,7 @@ describe('SupersetClient', () => { expect(initSpy).toHaveBeenCalledTimes(2); expect(deleteSpy).toHaveBeenCalledTimes(1); expect(putSpy).toHaveBeenCalledTimes(1); - expect(getSpy).toHaveBeenCalledTimes(2); + expect(getSpy).toHaveBeenCalledTimes(1); expect(postSpy).toHaveBeenCalledTimes(1); expect(requestSpy).toHaveBeenCalledTimes(5); // request rewires to get expect(csrfSpy).toHaveBeenCalledTimes(2); // from init() + reAuthenticate() diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/package.json b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/package.json index 72eb975c845e4..88342df95b0e9 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/package.json +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/package.json @@ -34,9 +34,15 @@ "@storybook/addon-knobs": "^4.0.2", "@storybook/addon-options": "^4.0.3", "@storybook/react": "^4.0.2", + "@superset-ui/chart": "^0.10.2", "@superset-ui/color": "^0.10.1", "@superset-ui/connection": "^0.10.2", + "@superset-ui/legacy-plugin-chart-sankey": "^0.10.0", + "@superset-ui/legacy-plugin-chart-sunburst": "^0.10.0", + "@superset-ui/legacy-plugin-chart-word-cloud": "^0.10.0", + "@superset-ui/legacy-preset-chart-big-number": "^0.10.0", "@superset-ui/number-format": "^0.10.1", + "@superset-ui/plugin-chart-word-cloud": "^0.10.0", "@superset-ui/time-format": "^0.10.1", "babel-loader": "^8.0.4", "bootstrap": "^4.2.1", diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/index.js b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/index.js index 25480a59dad1b..574189d16a1b7 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/index.js +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/index.js @@ -20,7 +20,7 @@ const EMPTY_EXAMPLES = [ * { storyPath: string, storyName: string, renderStory: fn() => node } * */ -const requireContext = require.context('./', /* subdirs= */ true, /index\.jsx?$/); +const requireContext = require.context('./', /* subdirs= */ true, /index\.(j|t)sx?$/); requireContext.keys().forEach(packageName => { const packageExport = requireContext(packageName); diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-chart/ChartDataProviderStories.tsx b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-chart/ChartDataProviderStories.tsx new file mode 100644 index 0000000000000..5675c9a6fb2ff --- /dev/null +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-chart/ChartDataProviderStories.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { text, select } from '@storybook/addon-knobs'; + +import { SuperChart, ChartDataProvider, ChartProps } from '@superset-ui/chart'; +import { SupersetClient } from '@superset-ui/connection'; +import { BigNumberChartPlugin as LegacyBigNumberPlugin } from '@superset-ui/legacy-preset-chart-big-number'; +import LegacySankeyPlugin from '@superset-ui/legacy-plugin-chart-sankey'; +import LegacySunburstPlugin from '@superset-ui/legacy-plugin-chart-sunburst'; +import LegacyWordCloudPlugin from '@superset-ui/legacy-plugin-chart-word-cloud'; +import WordCloudPlugin from '@superset-ui/plugin-chart-word-cloud'; + +import { DataProviderProvidedProps } from '@superset-ui/chart/src'; +import { + bigNumberFormData, + sankeyFormData, + sunburstFormData, + wordCloudFormData, +} from '@superset-ui/chart/test/fixtures/formData'; + +import Expandable from '../../shared/components/Expandable'; +import VerifyCORS, { renderError } from '../../shared/components/VerifyCORS'; + +const BIG_NUMBER = bigNumberFormData.viz_type; +const SANKEY = sankeyFormData.viz_type; +const SUNBURST = sunburstFormData.viz_type; +const WORD_CLOUD_LEGACY = wordCloudFormData.viz_type; +const WORD_CLOUD = 'new_word_cloud'; + +new LegacyBigNumberPlugin().configure({ key: BIG_NUMBER }).register(); +// @ts-ignore +new LegacySankeyPlugin().configure({ key: SANKEY }).register(); +// @ts-ignore +new LegacySunburstPlugin().configure({ key: SUNBURST }).register(); +// @ts-ignore +new LegacyWordCloudPlugin().configure({ key: WORD_CLOUD_LEGACY }).register(); +// @ts-ignore +new WordCloudPlugin().configure({ key: WORD_CLOUD }).register(); + +const VIS_TYPES = [BIG_NUMBER, SANKEY, SUNBURST, WORD_CLOUD, WORD_CLOUD_LEGACY]; +const FORM_DATA_LOOKUP = { + [BIG_NUMBER]: bigNumberFormData, + [SANKEY]: sankeyFormData, + [SUNBURST]: sunburstFormData, + [WORD_CLOUD]: { ...wordCloudFormData, viz_type: WORD_CLOUD }, + [WORD_CLOUD_LEGACY]: wordCloudFormData, +}; + +export default [ + { + renderStory: () => { + const host = text('Set Superset App host for CORS request', 'localhost:9000'); + const visType = select('Chart Plugin Type', VIS_TYPES, VIS_TYPES[0]); + const formData = text('Override formData', JSON.stringify(FORM_DATA_LOOKUP[visType])); + const width = text('Vis width', '500'); + const height = text('Vis height', '300'); + + return ( +
+ + {() => ( + + {({ loading, payload, error }: DataProviderProvidedProps) => { + if (loading) return
Loading!
; + + if (error) return renderError(error); + + if (payload) + return ( + <> + +
+ +
{JSON.stringify(payload, null, 2)}
+
+ + ); + + return null; + }} +
+ )} +
+
+ ); + }, + storyName: 'ChartDataProvider', + storyPath: '@superset-ui/chart', + }, +]; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-chart/index.ts b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-chart/index.ts new file mode 100644 index 0000000000000..aa39e3d724fee --- /dev/null +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-chart/index.ts @@ -0,0 +1,5 @@ +import ChartDataProviderStories from './ChartDataProviderStories'; + +export default { + examples: [...ChartDataProviderStories], +}; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-connection/ConnectionStories.jsx b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-connection/ConnectionStories.jsx index cb3e9eea62120..9cbaa8fca0384 100644 --- a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-connection/ConnectionStories.jsx +++ b/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-demo/storybook/stories/superset-ui-connection/ConnectionStories.jsx @@ -3,7 +3,7 @@ import { select, text } from '@storybook/addon-knobs'; import VerifyCORS from '../../shared/components/VerifyCORS'; import Expandable from '../../shared/components/Expandable'; -import { bigNumberFormData } from '../mocks/formData'; +import { bigNumberFormData } from '../../../../superset-ui-chart/test/fixtures/formData'; const REQUEST_METHODS = ['GET', 'POST']; diff --git a/superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-connection/types/external.d.ts b/superset-frontend/temporary_superset_ui/superset-ui/types/external.d.ts similarity index 100% rename from superset-frontend/temporary_superset_ui/superset-ui/packages/superset-ui-connection/types/external.d.ts rename to superset-frontend/temporary_superset_ui/superset-ui/types/external.d.ts