Skip to content

Commit

Permalink
feat(chart): Add <ChartDataProvider /> (#120)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
williaster authored and zhaoyongjie committed Nov 25, 2021
1 parent d29fa1b commit 1c0a92e
Show file tree
Hide file tree
Showing 22 changed files with 726 additions and 48 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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}\"",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,86 @@ Description

#### Example usage

```js
import { xxx } from '@superset-ui/chart';
```
##### `<ChartDataProvider />`

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 = () => (
<DataProvider client={client} formData={formData}>
{({ loading, error, payload }) => (
<>
{loading && <Loader />}

{error && <RenderError error={error} />}

{payload && (
<SuperChart type={CHART_TYPE} chartProps={{ formData, payload, width, height }} />
)}
</>
)}
</DataProvider>
);
```

`fn(args)`
##### `<SuperChart />`

- Do something
Coming soon.

### Development

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChartFormData>;
}
| {
formData: ChartFormData;
};
// This expands to Partial<All> & (union of all possible single-property types)
type AtLeastOne<All, Each = { [K in keyof All]: Pick<All, K> }> = Partial<All> & Each[keyof Each];

export type SliceIdAndOrFormData = AtLeastOne<{
sliceId: number;
formData: Partial<ChartFormData>;
}>;

interface AnnotationData {
[key: string]: object;
}

interface ChartData {
export interface ChartData {
annotationData: AnnotationData;
datasource: object;
formData: ChartFormData;
queryData: object;
queryData: QueryData;
}

export default class ChartClient {
Expand All @@ -42,7 +44,10 @@ export default class ChartClient {
this.client = client;
}

loadFormData(input: SliceIdAndOrFormData, options?: RequestConfig): Promise<ChartFormData> {
loadFormData(
input: SliceIdAndOrFormData,
options?: Partial<RequestConfig>,
): Promise<ChartFormData> {
/* If sliceId is provided, use it to fetch stored formData from API */
if ('sliceId' in input) {
const promise = this.client
Expand All @@ -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<object> {
const buildQuery = getChartBuildQueryRegistry().get(formData.viz_type);
if (buildQuery) {
loadQueryData(formData: ChartFormData, options?: Partial<RequestConfig>): Promise<object> {
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<object> {
loadDatasource(datasourceKey: string, options?: Partial<RequestConfig>): Promise<Datasource> {
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<object> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ChartFormData>;
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<RequestConfig>;
/** Hook to override the datasource request config. */
datasourceRequestOptions?: Partial<RequestConfig>;
/** Hook to override the queryData request config. */
queryRequestOptions?: Partial<RequestConfig>;
};

type State = {
status: 'uninitialized' | 'loading' | 'error' | 'loaded';
payload?: ProvidedProps['payload'];
error?: ProvidedProps['error'];
};

class ChartDataProvider extends React.PureComponent<Props, State> {
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;
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<MetricKey, FormDataMetric | FormDataMetric[]>>;
export type Metrics = Partial<Record<MetricKey, FormDataMetric | FormDataMetric[]>>;

type BaseFormData = {
export type BaseFormData = {
datasource: string;
viz_type: string;
annotation_layers?: AnnotationLayerMetadata[];
Expand Down
Original file line number Diff line number Diff line change
@@ -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[];
}
Loading

0 comments on commit 1c0a92e

Please sign in to comment.