fix(plugin-chart-handlebars): fix overflow, debounce and control reset (#19879)

* fix(plugin-chart-handlebars): fix overflow

* add debounce, fix reset controls

* fix deps

* remove redundant code

* improve examples

* add last missing resetOnHides

* fix test

* use isPlainObject
This commit is contained in:
Ville Brofeldt 2022-04-28 16:38:23 +03:00 committed by GitHub
parent 1d50665da0
commit d5ea537b0e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 56 additions and 171 deletions

View File

@ -26,19 +26,20 @@
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"@superset-ui/chart-controls": "0.18.25", "handlebars": "^4.7.7"
"@superset-ui/core": "0.18.25",
"ace-builds": "^1.4.13",
"emotion": "^11.0.0",
"handlebars": "^4.7.7",
"react-ace": "^9.4.4"
}, },
"peerDependencies": { "peerDependencies": {
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"ace-builds": "^1.4.14",
"lodash": "^4.17.11",
"moment": "^2.26.0", "moment": "^2.26.0",
"react": "^16.13.1", "react": "^16.13.1",
"react-ace": "^9.4.4",
"react-dom": "^16.13.1" "react-dom": "^16.13.1"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash": "^4.14.149",
"@types/jest": "^26.0.0", "@types/jest": "^26.0.0",
"jest": "^26.0.1" "jest": "^26.0.1"
} }

View File

@ -17,36 +17,19 @@
* under the License. * under the License.
*/ */
import { styled } from '@superset-ui/core'; import { styled } from '@superset-ui/core';
import React, { createRef, useEffect } from 'react'; import React, { createRef } from 'react';
import { HandlebarsViewer } from './components/Handlebars/HandlebarsViewer'; import { HandlebarsViewer } from './components/Handlebars/HandlebarsViewer';
import { HandlebarsProps, HandlebarsStylesProps } from './types'; import { HandlebarsProps, HandlebarsStylesProps } from './types';
// The following Styles component is a <div> element, which has been styled using Emotion
// For docs, visit https://emotion.sh/docs/styled
// Theming variables are provided for your use via a ThemeProvider
// imported from @superset-ui/core. For variables available, please visit
// https://github.com/apache-superset/superset-ui/blob/master/packages/superset-ui-core/src/style/index.ts
const Styles = styled.div<HandlebarsStylesProps>` const Styles = styled.div<HandlebarsStylesProps>`
padding: ${({ theme }) => theme.gridUnit * 4}px; padding: ${({ theme }) => theme.gridUnit * 4}px;
border-radius: ${({ theme }) => theme.gridUnit * 2}px; border-radius: ${({ theme }) => theme.gridUnit * 2}px;
height: ${({ height }) => height}; height: ${({ height }) => height}px;
width: ${({ width }) => width}; width: ${({ width }) => width}px;
overflow-y: scroll; overflow: auto;
`; `;
/**
* ******************* WHAT YOU CAN BUILD HERE *******************
* In essence, a chart is given a few key ingredients to work with:
* * Data: provided via `props.data`
* * A DOM element
* * FormData (your controls!) provided as props by transformProps.ts
*/
export default function Handlebars(props: HandlebarsProps) { export default function Handlebars(props: HandlebarsProps) {
// height and width are the height and width of the DOM element as it exists in the dashboard.
// There is also a `data` prop, which is, of course, your DATA 🎉
const { data, height, width, formData } = props; const { data, height, width, formData } = props;
const styleTemplateSource = formData.styleTemplate const styleTemplateSource = formData.styleTemplate
? `<style>${formData.styleTemplate}</style>` ? `<style>${formData.styleTemplate}</style>`
@ -58,13 +41,6 @@ export default function Handlebars(props: HandlebarsProps) {
const rootElem = createRef<HTMLDivElement>(); const rootElem = createRef<HTMLDivElement>();
// Often, you just want to get a hold of the DOM and go nuts.
// Here, you can do that with createRef, and the useEffect hook.
useEffect(() => {
// const root = rootElem.current as HTMLElement;
// console.log('Plugin element', root);
});
return ( return (
<Styles ref={rootElem} height={height} width={width}> <Styles ref={rootElem} height={height} width={width}>
<HandlebarsViewer data={{ data }} templateSource={templateSource} /> <HandlebarsViewer data={{ data }} templateSource={templateSource} />

View File

@ -20,6 +20,7 @@ import { SafeMarkdown, styled } from '@superset-ui/core';
import Handlebars from 'handlebars'; import Handlebars from 'handlebars';
import moment from 'moment'; import moment from 'moment';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { isPlainObject } from 'lodash';
export interface HandlebarsViewerProps { export interface HandlebarsViewerProps {
templateSource: string; templateSource: string;
@ -64,3 +65,11 @@ Handlebars.registerHelper('dateFormat', function (context, block) {
const f = block.hash.format || 'YYYY-MM-DD'; const f = block.hash.format || 'YYYY-MM-DD';
return moment(context).format(f); return moment(context).format(f);
}); });
// usage: {{ }}
Handlebars.registerHelper('stringify', (obj: any, obj2: any) => {
// calling without an argument
if (obj2 === undefined)
throw Error('Please call with an object. Example: `stringify myObj`');
return isPlainObject(obj) ? JSON.stringify(obj) : String(obj);
});

View File

@ -16,8 +16,9 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { debounce } from 'lodash';
import { formatSelectOptions } from '@superset-ui/chart-controls'; import { formatSelectOptions } from '@superset-ui/chart-controls';
import { addLocaleData, t } from '@superset-ui/core'; import { addLocaleData, SLOW_DEBOUNCE, t } from '@superset-ui/core';
import i18n from './i18n'; import i18n from './i18n';
addLocaleData(i18n); addLocaleData(i18n);
@ -35,3 +36,8 @@ export const PAGE_SIZE_OPTIONS = formatSelectOptions<number>([
100, 100,
200, 200,
]); ]);
export const debounceFunc = debounce(
(func: (val: string) => void, source: string) => func(source),
SLOW_DEBOUNCE,
);

View File

@ -50,81 +50,6 @@ import { styleControlSetItem } from './controls/style';
addLocaleData(i18n); addLocaleData(i18n);
const config: ControlPanelConfig = { const config: ControlPanelConfig = {
/**
* The control panel is split into two tabs: "Query" and
* "Chart Options". The controls that define the inputs to
* the chart data request, such as columns and metrics, usually
* reside within "Query", while controls that affect the visual
* appearance or functionality of the chart are under the
* "Chart Options" section.
*
* There are several predefined controls that can be used.
* Some examples:
* - groupby: columns to group by (tranlated to GROUP BY statement)
* - series: same as groupby, but single selection.
* - metrics: multiple metrics (translated to aggregate expression)
* - metric: sane as metrics, but single selection
* - adhoc_filters: filters (translated to WHERE or HAVING
* depending on filter type)
* - row_limit: maximum number of rows (translated to LIMIT statement)
*
* If a control panel has both a `series` and `groupby` control, and
* the user has chosen `col1` as the value for the `series` control,
* and `col2` and `col3` as values for the `groupby` control,
* the resulting query will contain three `groupby` columns. This is because
* we considered `series` control a `groupby` query field and its value
* will automatically append the `groupby` field when the query is generated.
*
* It is also possible to define custom controls by importing the
* necessary dependencies and overriding the default parameters, which
* can then be placed in the `controlSetRows` section
* of the `Query` section instead of a predefined control.
*
* import { validateNonEmpty } from '@superset-ui/core';
* import {
* sharedControls,
* ControlConfig,
* ControlPanelConfig,
* } from '@superset-ui/chart-controls';
*
* const myControl: ControlConfig<'SelectControl'> = {
* name: 'secondary_entity',
* config: {
* ...sharedControls.entity,
* type: 'SelectControl',
* label: t('Secondary Entity'),
* mapStateToProps: state => ({
* sharedControls.columnChoices(state.datasource)
* .columns.filter(c => c.groupby)
* })
* validators: [validateNonEmpty],
* },
* }
*
* In addition to the basic drop down control, there are several predefined
* control types (can be set via the `type` property) that can be used. Some
* commonly used examples:
* - SelectControl: Dropdown to select single or multiple values,
usually columns
* - MetricsControl: Dropdown to select metrics, triggering a modal
to define Metric details
* - AdhocFilterControl: Control to choose filters
* - CheckboxControl: A checkbox for choosing true/false values
* - SliderControl: A slider with min/max values
* - TextControl: Control for text data
*
* For more control input types, check out the `incubator-superset` repo
* and open this file: superset-frontend/src/explore/components/controls/index.js
*
* To ensure all controls have been filled out correctly, the following
* validators are provided
* by the `@superset-ui/core/lib/validator`:
* - validateNonEmpty: must have at least one value
* - validateInteger: must be an integer value
* - validateNumber: must be an intger or decimal value
*/
// For control input types, see: superset-frontend/src/explore/components/controls/index.js
controlPanelSections: [ controlPanelSections: [
sections.legacyTimeseriesTime, sections.legacyTimeseriesTime,
{ {

View File

@ -31,7 +31,7 @@ import {
import React from 'react'; import React from 'react';
import { getQueryMode, isRawMode } from './shared'; import { getQueryMode, isRawMode } from './shared';
export const allColumns: typeof sharedControls.groupby = { const allColumns: typeof sharedControls.groupby = {
type: 'SelectControl', type: 'SelectControl',
label: t('Columns'), label: t('Columns'),
description: t('Columns to display'), description: t('Columns to display'),
@ -52,6 +52,7 @@ export const allColumns: typeof sharedControls.groupby = {
: [], : [],
}), }),
visibility: isRawMode, visibility: isRawMode,
resetOnHide: false,
}; };
const dndAllColumns: typeof sharedControls.groupby = { const dndAllColumns: typeof sharedControls.groupby = {
@ -75,6 +76,7 @@ const dndAllColumns: typeof sharedControls.groupby = {
return newState; return newState;
}, },
visibility: isRawMode, visibility: isRawMode,
resetOnHide: false,
}; };
export const allColumnsControlSetItem: ControlSetItem = { export const allColumnsControlSetItem: ControlSetItem = {

View File

@ -28,6 +28,7 @@ export const groupByControlSetItem: ControlSetItem = {
name: 'groupby', name: 'groupby',
override: { override: {
visibility: isAggMode, visibility: isAggMode,
resetOnHide: false,
mapStateToProps: (state: ControlPanelState, controlState: ControlState) => { mapStateToProps: (state: ControlPanelState, controlState: ControlState) => {
const { controls } = state; const { controls } = state;
const originalMapStateToProps = sharedControls?.groupby?.mapStateToProps; const originalMapStateToProps = sharedControls?.groupby?.mapStateToProps;
@ -37,7 +38,6 @@ export const groupByControlSetItem: ControlSetItem = {
controls.percent_metrics?.value, controls.percent_metrics?.value,
controlState.value, controlState.value,
]); ]);
return newState; return newState;
}, },
rerender: ['metrics', 'percent_metrics'], rerender: ['metrics', 'percent_metrics'],

View File

@ -25,6 +25,7 @@ import { t, validateNonEmpty } from '@superset-ui/core';
import React from 'react'; import React from 'react';
import { CodeEditor } from '../../components/CodeEditor/CodeEditor'; import { CodeEditor } from '../../components/CodeEditor/CodeEditor';
import { ControlHeader } from '../../components/ControlHeader/controlHeader'; import { ControlHeader } from '../../components/ControlHeader/controlHeader';
import { debounceFunc } from '../../consts';
interface HandlebarsCustomControlProps { interface HandlebarsCustomControlProps {
value: string; value: string;
@ -37,9 +38,6 @@ const HandlebarsTemplateControl = (
props?.value ? props?.value : props?.default ? props?.default : '', props?.value ? props?.value : props?.default ? props?.default : '',
); );
const updateConfig = (source: string) => {
props.onChange(source);
};
return ( return (
<div> <div>
<ControlHeader>{props.label}</ControlHeader> <ControlHeader>{props.label}</ControlHeader>
@ -47,7 +45,7 @@ const HandlebarsTemplateControl = (
theme="dark" theme="dark"
value={val} value={val}
onChange={source => { onChange={source => {
updateConfig(source || ''); debounceFunc(props.onChange, source || '');
}} }}
/> />
</div> </div>
@ -61,11 +59,11 @@ export const handlebarsTemplateControlSetItem: ControlSetItem = {
type: HandlebarsTemplateControl, type: HandlebarsTemplateControl,
label: t('Handlebars Template'), label: t('Handlebars Template'),
description: t('A handlebars template that is applied to the data'), description: t('A handlebars template that is applied to the data'),
default: `<ul class="data_list"> default: `<ul class="data-list">
{{#each data}} {{#each data}}
<li>{{this}}</li> <li>{{stringify this}}</li>
{{/each}} {{/each}}
</ul>`, </ul>`,
isInt: false, isInt: false,
renderTrigger: true, renderTrigger: true,

View File

@ -30,5 +30,6 @@ export const includeTimeControlSetItem: ControlSetItem = {
), ),
default: false, default: false,
visibility: isAggMode, visibility: isAggMode,
resetOnHide: false,
}, },
}; };

View File

@ -34,5 +34,6 @@ export const timeSeriesLimitMetricControlSetItem: ControlSetItem = {
name: 'timeseries_limit_metric', name: 'timeseries_limit_metric',
override: { override: {
visibility: isAggMode, visibility: isAggMode,
resetOnHide: false,
}, },
}; };

View File

@ -33,6 +33,7 @@ const percentMetrics: typeof sharedControls.metrics = {
), ),
multi: true, multi: true,
visibility: isAggMode, visibility: isAggMode,
resetOnHide: false,
mapStateToProps: ({ datasource, controls }, controlState) => ({ mapStateToProps: ({ datasource, controls }, controlState) => ({
columns: datasource?.columns || [], columns: datasource?.columns || [],
savedMetrics: datasource?.metrics || [], savedMetrics: datasource?.metrics || [],
@ -86,6 +87,7 @@ export const metricsControlSetItem: ControlSetItem = {
]), ]),
}), }),
rerender: ['groupby', 'percent_metrics'], rerender: ['groupby', 'percent_metrics'],
resetOnHide: false,
}, },
}; };
@ -99,5 +101,6 @@ export const showTotalsControlSetItem: ControlSetItem = {
'Show total aggregations of selected metrics. Note that row limit does not apply to the result.', 'Show total aggregations of selected metrics. Note that row limit does not apply to the result.',
), ),
visibility: isAggMode, visibility: isAggMode,
resetOnHide: false,
}, },
}; };

View File

@ -32,6 +32,7 @@ export const orderByControlSetItem: ControlSetItem = {
choices: datasource?.order_by_choices || [], choices: datasource?.order_by_choices || [],
}), }),
visibility: isRawMode, visibility: isRawMode,
resetOnHide: false,
}, },
}; };
@ -43,5 +44,6 @@ export const orderDescendingControlSetItem: ControlSetItem = {
default: true, default: true,
description: t('Whether to sort descending or ascending'), description: t('Whether to sort descending or ascending'),
visibility: isAggMode, visibility: isAggMode,
resetOnHide: false,
}, },
}; };

View File

@ -25,6 +25,7 @@ import { t } from '@superset-ui/core';
import React from 'react'; import React from 'react';
import { CodeEditor } from '../../components/CodeEditor/CodeEditor'; import { CodeEditor } from '../../components/CodeEditor/CodeEditor';
import { ControlHeader } from '../../components/ControlHeader/controlHeader'; import { ControlHeader } from '../../components/ControlHeader/controlHeader';
import { debounceFunc } from '../../consts';
interface StyleCustomControlProps { interface StyleCustomControlProps {
value: string; value: string;
@ -35,9 +36,6 @@ const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
props?.value ? props?.value : props?.default ? props?.default : '', props?.value ? props?.value : props?.default ? props?.default : '',
); );
const updateConfig = (source: string) => {
props.onChange(source);
};
return ( return (
<div> <div>
<ControlHeader>{props.label}</ControlHeader> <ControlHeader>{props.label}</ControlHeader>
@ -46,7 +44,7 @@ const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
mode="css" mode="css"
value={val} value={val}
onChange={source => { onChange={source => {
updateConfig(source || ''); debounceFunc(props.onChange, source || '');
}} }}
/> />
</div> </div>
@ -60,7 +58,11 @@ export const styleControlSetItem: ControlSetItem = {
type: StyleControl, type: StyleControl,
label: t('CSS Styles'), label: t('CSS Styles'),
description: t('CSS applied to the chart'), description: t('CSS applied to the chart'),
default: '', default: `/*
.data-list {
background-color: yellow;
}
*/`,
isInt: false, isInt: false,
renderTrigger: true, renderTrigger: true,

View File

@ -19,49 +19,13 @@
import { ChartProps, TimeseriesDataRecord } from '@superset-ui/core'; import { ChartProps, TimeseriesDataRecord } from '@superset-ui/core';
export default function transformProps(chartProps: ChartProps) { export default function transformProps(chartProps: ChartProps) {
/**
* This function is called after a successful response has been
* received from the chart data endpoint, and is used to transform
* the incoming data prior to being sent to the Visualization.
*
* The transformProps function is also quite useful to return
* additional/modified props to your data viz component. The formData
* can also be accessed from your Handlebars.tsx file, but
* doing supplying custom props here is often handy for integrating third
* party libraries that rely on specific props.
*
* A description of properties in `chartProps`:
* - `height`, `width`: the height/width of the DOM element in which
* the chart is located
* - `formData`: the chart data request payload that was sent to the
* backend.
* - `queriesData`: the chart data response payload that was received
* from the backend. Some notable properties of `queriesData`:
* - `data`: an array with data, each row with an object mapping
* the column/alias to its value. Example:
* `[{ col1: 'abc', metric1: 10 }, { col1: 'xyz', metric1: 20 }]`
* - `rowcount`: the number of rows in `data`
* - `query`: the query that was issued.
*
* Please note: the transformProps function gets cached when the
* application loads. When making changes to the `transformProps`
* function during development with hot reloading, changes won't
* be seen until restarting the development server.
*/
const { width, height, formData, queriesData } = chartProps; const { width, height, formData, queriesData } = chartProps;
const data = queriesData[0].data as TimeseriesDataRecord[]; const data = queriesData[0].data as TimeseriesDataRecord[];
return { return {
width, width,
height, height,
data,
data: data.map(item => ({
...item,
// convert epoch to native Date
// eslint-disable-next-line no-underscore-dangle
__timestamp: new Date(item.__timestamp as number),
})),
// and now your control data, manipulated as needed, and passed through as props!
formData, formData,
}; };
} }

View File

@ -31,15 +31,12 @@ describe('Handlebars tranformProps', () => {
height: 500, height: 500,
viz_type: 'handlebars', viz_type: 'handlebars',
}; };
const data = [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }];
const chartProps = new ChartProps<QueryFormData>({ const chartProps = new ChartProps<QueryFormData>({
formData, formData,
width: 800, width: 800,
height: 600, height: 600,
queriesData: [ queriesData: [{ data }],
{
data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }],
},
],
}); });
it('should tranform chart props for viz', () => { it('should tranform chart props for viz', () => {
@ -47,9 +44,7 @@ describe('Handlebars tranformProps', () => {
expect.objectContaining({ expect.objectContaining({
width: 800, width: 800,
height: 600, height: 600,
data: [ data,
{ name: 'Hulk', sum__num: 1, __timestamp: new Date(599616000000) },
],
}), }),
); );
}); });