From d8d2bb2046c81985abf5ad51d8a420668adec20c Mon Sep 17 00:00:00 2001 From: Krist Wongsuphasawat Date: Wed, 12 Dec 2018 13:18:27 -0800 Subject: [PATCH] Add ChartClient (v2) (#57) feat: Add ChartClient --- packages/superset-ui-chart/package.json | 2 + .../src/clients/ChartClient.ts | 138 +++++++++++ packages/superset-ui-chart/src/index.js | 1 + .../src/models/ChartPlugin.ts | 4 + .../superset-ui-chart/src/query/FormData.ts | 9 + .../test/clients/ChartClient.test.ts | 231 ++++++++++++++++++ packages/superset-ui-chart/test/index.test.js | 2 + .../test/models/ChartPlugin.test.ts | 6 +- .../test/query/Metric.test.ts | 1 + .../test/query/buildQueryContext.test.ts | 12 +- .../test/query/buildQueryObject.test.ts | 13 +- .../src/SupersetClient.ts | 17 +- 12 files changed, 422 insertions(+), 14 deletions(-) create mode 100644 packages/superset-ui-chart/src/clients/ChartClient.ts create mode 100644 packages/superset-ui-chart/test/clients/ChartClient.test.ts diff --git a/packages/superset-ui-chart/package.json b/packages/superset-ui-chart/package.json index 062ca71f1a06e..e6ba2834a202d 100644 --- a/packages/superset-ui-chart/package.json +++ b/packages/superset-ui-chart/package.json @@ -33,6 +33,8 @@ }, "devDependencies": { "@superset-ui/connection": "^0.7.0", + "fetch-mock": "^7.2.5", + "node-fetch": "^2.2.0", "react": "^15 || ^16" }, "peerDependencies": { diff --git a/packages/superset-ui-chart/src/clients/ChartClient.ts b/packages/superset-ui-chart/src/clients/ChartClient.ts new file mode 100644 index 0000000000000..70993a10fc022 --- /dev/null +++ b/packages/superset-ui-chart/src/clients/ChartClient.ts @@ -0,0 +1,138 @@ +import { isDefined } from '@superset-ui/core'; +import { + SupersetClient, + RequestConfig, + SupersetClientClass, + SupersetClientResponse, + Json, +} from '@superset-ui/connection'; +import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton'; +import { FormData, AnnotationLayerMetadata } from '../query/FormData'; + +interface SliceIdAndOrFormData { + sliceId?: number; + formData?: FormData; +} + +interface AnnotationData { + [key: string]: object; +} + +interface ChartData { + annotationData: AnnotationData; + datasource: object; + formData: FormData; + queryData: object; +} + +export interface ChartClientConfig { + client?: SupersetClientClass; +} + +export class ChartClient { + readonly client: { + get: (config: RequestConfig) => Promise; + post: (config: RequestConfig) => Promise; + }; + + constructor(config: ChartClientConfig = {}) { + const { client = SupersetClient } = config; + this.client = client; + } + + loadFormData(input: SliceIdAndOrFormData, options?: RequestConfig): Promise { + /* If sliceId is provided, use it to fetch stored formData from API */ + if (input.sliceId) { + const promise = this.client + .get({ + endpoint: `/api/v1/formData/?slice_id=${input.sliceId}`, + ...options, + } as RequestConfig) + .then(response => response.json as Json) + .then(json => json.form_data); + + /* + * If formData is also specified, override API result + * with user-specified formData + */ + return input.formData + ? promise.then( + (dbFormData: object) => + ({ + ...dbFormData, + ...input.formData, + } as FormData), + ) + : promise.then((dbFormData: object) => dbFormData as FormData); + } + + /* If sliceId is not provided, returned formData wrapped in a Promise */ + return input.formData + ? Promise.resolve(input.formData) + : Promise.reject(new Error('At least one of sliceId or formData must be specified')); + } + + loadQueryData(formData: FormData, options?: RequestConfig): Promise { + const buildQuery = getChartBuildQueryRegistry().get(formData.viz_type); + if (buildQuery) { + return this.client + .post({ + endpoint: '/api/v1/query/', + postPayload: { query_context: buildQuery(formData) }, + ...options, + } as RequestConfig) + .then(response => response.json as Json); + } + + return Promise.reject(new Error(`Unknown chart type: ${formData.viz_type}`)); + } + + loadDatasource(datasourceKey: string, options?: RequestConfig): Promise { + return this.client + .get({ + endpoint: `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`, + ...options, + } as RequestConfig) + .then(response => response.json as Json); + } + + loadAnnotation(annotationLayer: AnnotationLayerMetadata): Promise { + /* When annotation does not require query */ + if (!isDefined(annotationLayer.sourceType)) { + return Promise.resolve({}); + } + + // TODO: Implement + return Promise.reject(new Error('This feature is not implemented yet.')); + } + + loadAnnotations(annotationLayers?: Array): Promise { + if (Array.isArray(annotationLayers) && annotationLayers.length > 0) { + return Promise.all(annotationLayers.map(layer => this.loadAnnotation(layer))).then(results => + annotationLayers.reduce((prev, layer, i) => { + const output: AnnotationData = prev; + output[layer.name] = results[i]; + + return output; + }, {}), + ); + } + + return Promise.resolve({}); + } + + loadChartData(input: SliceIdAndOrFormData): Promise { + return this.loadFormData(input).then(formData => + Promise.all([ + this.loadAnnotations(formData.annotation_layers), + this.loadDatasource(formData.datasource), + this.loadQueryData(formData), + ]).then(([annotationData, datasource, queryData]) => ({ + annotationData, + datasource, + formData, + queryData, + })), + ); + } +} diff --git a/packages/superset-ui-chart/src/index.js b/packages/superset-ui-chart/src/index.js index 464e25f9d28ef..fbe574c2121d9 100644 --- a/packages/superset-ui-chart/src/index.js +++ b/packages/superset-ui-chart/src/index.js @@ -1,3 +1,4 @@ +export { ChartClient, ChartClientConfig } from './clients/ChartClient'; export { ChartMetadata, ChartMetadataConfig } from './models/ChartMetadata'; export { ChartPlugin, diff --git a/packages/superset-ui-chart/src/models/ChartPlugin.ts b/packages/superset-ui-chart/src/models/ChartPlugin.ts index 744db1f518a9a..77789dde0b014 100644 --- a/packages/superset-ui-chart/src/models/ChartPlugin.ts +++ b/packages/superset-ui-chart/src/models/ChartPlugin.ts @@ -76,4 +76,8 @@ export class ChartPlugin extends Plugin { return this; } + + configure(config: { [key: string]: any }): ChartPlugin { + return super.configure(config); + } } diff --git a/packages/superset-ui-chart/src/query/FormData.ts b/packages/superset-ui-chart/src/query/FormData.ts index 4fa23f6a0188d..b55c7d0f4d835 100644 --- a/packages/superset-ui-chart/src/query/FormData.ts +++ b/packages/superset-ui-chart/src/query/FormData.ts @@ -9,9 +9,18 @@ import { FormDataMetric, MetricKey } from './Metric'; // unified into a proper Metric type during buildQuery (see `/query/Metrics.ts`). type Metrics = Partial>; +export type AnnotationLayerMetadata = { + name: string; + sourceType?: string; +}; + +/* eslint-disable camelcase */ type BaseFormData = { datasource: string; + viz_type: string; + annotation_layers?: Array; } & Metrics; +/* eslint-enable camelcase */ // FormData is either sqla-based or druid-based type SqlaFormData = { diff --git a/packages/superset-ui-chart/test/clients/ChartClient.test.ts b/packages/superset-ui-chart/test/clients/ChartClient.test.ts new file mode 100644 index 0000000000000..52ee4407046a6 --- /dev/null +++ b/packages/superset-ui-chart/test/clients/ChartClient.test.ts @@ -0,0 +1,231 @@ +import fetchMock from 'fetch-mock'; +import { SupersetClientClass, SupersetClient } from '@superset-ui/connection'; + +import { ChartClient, getChartBuildQueryRegistry, buildQueryContext, FormData } from '../../src'; +import { LOGIN_GLOB } from '../../../superset-ui-connection/test/fixtures/constants'; + +describe('ChartClient', () => { + let chartClient; + + beforeAll(() => { + fetchMock.get(LOGIN_GLOB, { csrf_token: '1234' }); + SupersetClient.reset(); + SupersetClient.configure().init(); + }); + + beforeEach(() => { + chartClient = new ChartClient(); + }); + + afterEach(fetchMock.restore); + + describe('new ChartClient(config)', () => { + it('creates a client without argument', () => { + expect(chartClient).toBeInstanceOf(ChartClient); + }); + it('creates a client with specified config.client', () => { + const customClient = new SupersetClientClass(); + chartClient = new ChartClient({ client: customClient }); + expect(chartClient).toBeInstanceOf(ChartClient); + expect(chartClient.client).toBe(customClient); + }); + }); + + describe('.loadFormData({ sliceId, formData }, options)', () => { + it('fetches formData if given only sliceId', () => { + fetchMock.get('glob:*/api/v1/formData/?slice_id=123', { + form_data: { + granularity: 'minute', + field1: 'abc', + field2: 'def', + }, + }); + + return expect(chartClient.loadFormData({ sliceId: 123 })).resolves.toEqual({ + granularity: 'minute', + field1: 'abc', + field2: 'def', + }); + }); + it('fetches formData from sliceId and merges with specify formData if both fields are specified', () => { + fetchMock.get('glob:*/api/v1/formData/?slice_id=123', { + form_data: { + granularity: 'minute', + field1: 'abc', + field2: 'def', + }, + }); + + return expect( + chartClient.loadFormData({ + sliceId: 123, + formData: { + field2: 'ghi', + field3: 'jkl', + }, + }), + ).resolves.toEqual({ + granularity: 'minute', + field1: 'abc', + field2: 'ghi', + field3: 'jkl', + }); + }); + it('returns promise of formData if only formData was given', () => + expect( + chartClient.loadFormData({ + formData: { + field1: 'abc', + field2: 'def', + }, + }), + ).resolves.toEqual({ + field1: 'abc', + field2: 'def', + })); + it('rejects if none of sliceId or formData is specified', () => + expect(chartClient.loadFormData({})).rejects.toEqual( + new Error('At least one of sliceId or formData must be specified'), + )); + }); + + describe('.loadQueryData(formData, options)', () => { + it('returns a promise of query data for known chart type', () => { + getChartBuildQueryRegistry().registerValue('word_cloud', (formData: FormData) => + buildQueryContext(formData), + ); + fetchMock.post('glob:*/api/v1/query/', { + field1: 'abc', + field2: 'def', + }); + + return expect( + chartClient.loadQueryData({ + granularity: 'minute', + viz_type: 'word_cloud', + datasource: '1__table', + field3: 'abc', + field4: 'def', + }), + ).resolves.toEqual({ + field1: 'abc', + field2: 'def', + }); + }); + it('returns a promise that rejects for unknown chart type', () => + expect( + chartClient.loadQueryData({ + granularity: 'minute', + viz_type: 'rainbow_3d_pie', + datasource: '1__table', + field3: 'abc', + field4: 'def', + }), + ).rejects.toEqual(new Error('Unknown chart type: rainbow_3d_pie'))); + }); + + describe('.loadDatasource(datasourceKey, options)', () => { + it('fetches datasource', () => { + fetchMock.get('glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table', { + field1: 'abc', + field2: 'def', + }); + expect(chartClient.loadDatasource('1__table')).resolves.toEqual({ + field1: 'abc', + field2: 'def', + }); + }); + }); + + describe('.loadAnnotation(annotationLayer)', () => { + it('returns an empty object if the annotation layer does not require query', () => + expect( + chartClient.loadAnnotation({ + name: 'my-annotation', + }), + ).resolves.toEqual({})); + it('otherwise returns a rejected promise because it is not implemented yet', () => + expect( + chartClient.loadAnnotation({ + name: 'my-annotation', + sourceType: 'abc', + }), + ).rejects.toEqual(new Error('This feature is not implemented yet.'))); + }); + + describe('.loadAnnotations(annotationLayers)', () => { + it('loads multiple annotation layers and combine results', () => + expect( + chartClient.loadAnnotations([ + { + name: 'anno1', + }, + { + name: 'anno2', + }, + { + name: 'anno3', + }, + ]), + ).resolves.toEqual({ + anno1: {}, + anno2: {}, + anno3: {}, + })); + it('returns an empty object if input is not an array', () => + expect(chartClient.loadAnnotations()).resolves.toEqual({})); + it('returns an empty object if input is an empty array', () => + expect(chartClient.loadAnnotations()).resolves.toEqual({})); + }); + + describe('.loadChartData({ sliceId, formData })', () => { + it('loadAllDataNecessaryForAChart', () => { + fetchMock.get('glob:*/api/v1/formData/?slice_id=10120', { + form_data: { + granularity: 'minute', + viz_type: 'line', + datasource: '1__table', + color: 'living-coral', + }, + }); + + fetchMock.get('glob:*/superset/fetch_datasource_metadata?datasourceKey=1__table', { + name: 'transactions', + schema: 'staging', + }); + + fetchMock.post('glob:*/api/v1/query/', { + lorem: 'ipsum', + dolor: 'sit', + amet: true, + }); + + getChartBuildQueryRegistry().registerValue('line', (formData: FormData) => + buildQueryContext(formData), + ); + + return expect( + chartClient.loadChartData({ + sliceId: '10120', + }), + ).resolves.toEqual({ + annotationData: {}, + datasource: { + name: 'transactions', + schema: 'staging', + }, + formData: { + granularity: 'minute', + viz_type: 'line', + datasource: '1__table', + color: 'living-coral', + }, + queryData: { + lorem: 'ipsum', + dolor: 'sit', + amet: true, + }, + }); + }); + }); +}); diff --git a/packages/superset-ui-chart/test/index.test.js b/packages/superset-ui-chart/test/index.test.js index 8f2f4bc10e215..e365a9e818134 100644 --- a/packages/superset-ui-chart/test/index.test.js +++ b/packages/superset-ui-chart/test/index.test.js @@ -1,4 +1,5 @@ import { + ChartClient, ChartMetadata, ChartPlugin, ChartProps, @@ -13,6 +14,7 @@ import { describe('index', () => { it('exports modules', () => { [ + ChartClient, ChartMetadata, ChartPlugin, ChartProps, diff --git a/packages/superset-ui-chart/test/models/ChartPlugin.test.ts b/packages/superset-ui-chart/test/models/ChartPlugin.test.ts index faf5240383faf..53ec08769e84d 100644 --- a/packages/superset-ui-chart/test/models/ChartPlugin.test.ts +++ b/packages/superset-ui-chart/test/models/ChartPlugin.test.ts @@ -24,7 +24,11 @@ describe('ChartPlugin', () => { datasource: { id: 1, type: DatasourceType.Table }, queries: [{ granularity: 'day' }], }); - const FORM_DATA = { datasource: '1__table', granularity: 'day' }; + const FORM_DATA = { + datasource: '1__table', + granularity: 'day', + viz_type: 'table', + }; it('creates a new plugin', () => { const plugin = new ChartPlugin({ diff --git a/packages/superset-ui-chart/test/query/Metric.test.ts b/packages/superset-ui-chart/test/query/Metric.test.ts index f7566b3fea614..020cb3ad2786a 100644 --- a/packages/superset-ui-chart/test/query/Metric.test.ts +++ b/packages/superset-ui-chart/test/query/Metric.test.ts @@ -12,6 +12,7 @@ describe('Metrics', () => { const formData = { datasource: '5__table', granularity_sqla: 'ds', + viz_type: 'word_cloud', }; it('should build metrics for built-in metric keys', () => { diff --git a/packages/superset-ui-chart/test/query/buildQueryContext.test.ts b/packages/superset-ui-chart/test/query/buildQueryContext.test.ts index 2d1e1ad43e831..c4810eddd6455 100644 --- a/packages/superset-ui-chart/test/query/buildQueryContext.test.ts +++ b/packages/superset-ui-chart/test/query/buildQueryContext.test.ts @@ -2,13 +2,21 @@ import { buildQueryContext } from '../../src/query/buildQueryContext'; describe('queryContextBuilder', () => { it('should build datasource for table sources', () => { - const queryContext = buildQueryContext({ datasource: '5__table', granularity_sqla: 'ds' }); + const queryContext = buildQueryContext({ + datasource: '5__table', + granularity_sqla: 'ds', + viz_type: 'table', + }); expect(queryContext.datasource.id).toBe(5); expect(queryContext.datasource.type).toBe('table'); }); it('should build datasource for druid sources', () => { - const queryContext = buildQueryContext({ datasource: '5__druid', granularity: 'ds' }); + const queryContext = buildQueryContext({ + datasource: '5__druid', + granularity: 'ds', + viz_type: 'table', + }); expect(queryContext.datasource.id).toBe(5); expect(queryContext.datasource.type).toBe('druid'); }); diff --git a/packages/superset-ui-chart/test/query/buildQueryObject.test.ts b/packages/superset-ui-chart/test/query/buildQueryObject.test.ts index 95517a56f0fa2..22fa40f884eec 100644 --- a/packages/superset-ui-chart/test/query/buildQueryObject.test.ts +++ b/packages/superset-ui-chart/test/query/buildQueryObject.test.ts @@ -4,12 +4,20 @@ describe('queryObjectBuilder', () => { let query: QueryObject; it('should build granularity for sql alchemy datasources', () => { - query = buildQueryObject({ datasource: '5__table', granularity_sqla: 'ds' }); + query = buildQueryObject({ + datasource: '5__table', + granularity_sqla: 'ds', + viz_type: 'table', + }); expect(query.granularity).toEqual('ds'); }); it('should build granularity for sql druid datasources', () => { - query = buildQueryObject({ datasource: '5__druid', granularity: 'ds' }); + query = buildQueryObject({ + datasource: '5__druid', + granularity: 'ds', + viz_type: 'table', + }); expect(query.granularity).toEqual('ds'); }); @@ -17,6 +25,7 @@ describe('queryObjectBuilder', () => { query = buildQueryObject({ datasource: '5__table', granularity_sqla: 'ds', + viz_type: 'table', metric: 'sum__num', }); expect(query.metrics).toEqual([{ label: 'sum__num' }]); diff --git a/packages/superset-ui-connection/src/SupersetClient.ts b/packages/superset-ui-connection/src/SupersetClient.ts index 6c048f743116e..01dbea153f439 100644 --- a/packages/superset-ui-connection/src/SupersetClient.ts +++ b/packages/superset-ui-connection/src/SupersetClient.ts @@ -3,14 +3,12 @@ import { RequestConfig } from './types'; let singletonClient: SupersetClientClass | undefined; -function hasInstance( - maybeClient: SupersetClientClass | undefined, -): maybeClient is SupersetClientClass { +function getInstance(maybeClient: SupersetClientClass | undefined): SupersetClientClass { if (!maybeClient) { throw new Error('You must call SupersetClient.configure(...) before calling other methods'); } - return true; + return maybeClient; } const SupersetClient = { @@ -19,11 +17,12 @@ const SupersetClient = { return singletonClient; }, - get: (request: RequestConfig) => hasInstance(singletonClient) && singletonClient.get(request), - init: (force?: boolean) => hasInstance(singletonClient) && singletonClient.init(force), - isAuthenticated: () => hasInstance(singletonClient) && singletonClient.isAuthenticated(), - post: (request: RequestConfig) => hasInstance(singletonClient) && singletonClient.post(request), - reAuthenticate: () => hasInstance(singletonClient) && singletonClient.init(/* force = */ true), + get: (request: RequestConfig) => getInstance(singletonClient).get(request), + getInstance, + init: (force?: boolean) => getInstance(singletonClient).init(force), + isAuthenticated: () => getInstance(singletonClient).isAuthenticated(), + post: (request: RequestConfig) => getInstance(singletonClient).post(request), + reAuthenticate: () => getInstance(singletonClient).init(/* force = */ true), reset: () => { singletonClient = undefined; },