diff --git a/plugins/plugin-chart-handlebars/README.md b/plugins/plugin-chart-handlebars/README.md new file mode 100644 index 0000000000..f6beff307c --- /dev/null +++ b/plugins/plugin-chart-handlebars/README.md @@ -0,0 +1,55 @@ +## @superset-ui/plugin-chart-handlebars + +[![Version](https://img.shields.io/npm/v/@superset-ui/plugin-chart-handlebars.svg?style=flat-square)](https://www.npmjs.com/package/@superset-ui/plugin-chart-handlebars) + +This plugin provides Write a handlebars template to render the data for Superset. + +### Usage + +Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to +lookup this chart throughout the app. + +```js +import HandlebarsChartPlugin from '@superset-ui/plugin-chart-handlebars'; + +new HandlebarsChartPlugin().configure({ key: 'handlebars' }).register(); +``` + +Then use it via `SuperChart`. See +[storybook](https://apache-superset.github.io/superset-ui/?selectedKind=plugin-chart-handlebars) for +more details. + +```js + +``` + +### File structure generated + +``` +├── package.json +├── README.md +├── tsconfig.json +├── src +│   ├── Handlebars.tsx +│   ├── images +│   │   └── thumbnail.png +│   ├── index.ts +│   ├── plugin +│   │   ├── buildQuery.ts +│   │   ├── controlPanel.ts +│   │   ├── index.ts +│   │   └── transformProps.ts +│   └── types.ts +├── test +│   └── index.test.ts +└── types + └── external.d.ts +``` diff --git a/plugins/plugin-chart-handlebars/package.json b/plugins/plugin-chart-handlebars/package.json new file mode 100644 index 0000000000..939f170229 --- /dev/null +++ b/plugins/plugin-chart-handlebars/package.json @@ -0,0 +1,44 @@ +{ + "name": "@superset-ui/plugin-chart-handlebars", + "version": "0.0.0", + "description": "Superset Chart - Write a handlebars template to render the data", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" + }, + "keywords": [ + "superset" + ], + "author": "Superset", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/apache-superset/superset-ui/issues" + }, + "homepage": "https://github.com/apache-superset/superset-ui#readme", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@superset-ui/chart-controls": "0.18.22", + "@superset-ui/core": "0.18.22", + "ace-builds": "^1.4.13", + "emotion": "^11.0.0", + "handlebars": "^4.7.7", + "react-ace": "^9.4.4" + }, + "peerDependencies": { + "react": "^16.13.1", + "react-dom": "^16.13.1" + }, + "devDependencies": { + "@types/jest": "^26.0.0", + "jest": "^26.0.1" + } +} diff --git a/plugins/plugin-chart-handlebars/src/Handlebars.tsx b/plugins/plugin-chart-handlebars/src/Handlebars.tsx new file mode 100644 index 0000000000..b517cb41a2 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/Handlebars.tsx @@ -0,0 +1,74 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { styled } from '@superset-ui/core'; +import React, { createRef, useEffect } from 'react'; +import { HandlebarsViewer } from './components/Handlebars/HandlebarsViewer'; +import { HandlebarsProps, HandlebarsStylesProps } from './types'; + +// The following Styles component is a
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` + padding: ${({ theme }) => theme.gridUnit * 4}px; + border-radius: ${({ theme }) => theme.gridUnit * 2}px; + height: ${({ height }) => height}; + width: ${({ width }) => width}; + overflow-y: scroll; +`; + +/** + * ******************* 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) { + // 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 styleTemplateSource = formData.styleTemplate + ? `` + : ''; + const handlebarTemplateSource = formData.handlebarsTemplate + ? formData.handlebarsTemplate + : '{{data}}'; + const templateSource = `${handlebarTemplateSource}\n${styleTemplateSource} `; + + const rootElem = createRef(); + + // 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 ( + +

{props.headerText}

+ +
+ ); +} diff --git a/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx b/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx new file mode 100644 index 0000000000..adcab298d8 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/components/CodeEditor/CodeEditor.tsx @@ -0,0 +1,61 @@ +import React, { FC } from 'react'; +import AceEditor, { IAceEditorProps } from 'react-ace'; + +// must go after AceEditor import +import 'ace-builds/src-min-noconflict/mode-handlebars'; +import 'ace-builds/src-min-noconflict/mode-css'; +import 'ace-builds/src-noconflict/theme-github'; +import 'ace-builds/src-noconflict/theme-monokai'; + +export type CodeEditorMode = 'handlebars' | 'css'; +export type CodeEditorTheme = 'light' | 'dark'; + +export interface CodeEditorProps extends IAceEditorProps { + mode?: CodeEditorMode; + theme?: CodeEditorTheme; + name?: string; +} + +export const CodeEditor: FC = ({ + mode, + theme, + name, + width, + height, + value, + ...rest +}: CodeEditorProps) => { + const m_name = name || Math.random().toString(36).substring(7); + const m_theme = theme === 'light' ? 'github' : 'monokai'; + const m_mode = mode || 'handlebars'; + const m_height = height || '300px'; + const m_width = width || '100%'; + + return ( +
+ +
+ ); +}; diff --git a/plugins/plugin-chart-handlebars/src/components/ControlHeader/controlHeader.tsx b/plugins/plugin-chart-handlebars/src/components/ControlHeader/controlHeader.tsx new file mode 100644 index 0000000000..5297e0cdf0 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/components/ControlHeader/controlHeader.tsx @@ -0,0 +1,15 @@ +import React, { ReactNode } from 'react'; + +interface ControlHeaderProps { + children: ReactNode; +} + +export const ControlHeader = ({ + children, +}: ControlHeaderProps): JSX.Element => ( +
+
+ {children} +
+
+); diff --git a/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx b/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx new file mode 100644 index 0000000000..ea6d413fd6 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/components/Handlebars/HandlebarsViewer.tsx @@ -0,0 +1,26 @@ +import { SafeMarkdown } from '@superset-ui/core'; +import Handlebars from 'handlebars'; +import React, { useMemo, useState } from 'react'; + +export interface HandlebarsViewerProps { + templateSource: string; + data: any; +} + +export const HandlebarsViewer = ({ + templateSource, + data, +}: HandlebarsViewerProps) => { + const [renderedTemplate, setRenderedTemplate] = useState(''); + + useMemo(() => { + const template = Handlebars.compile(templateSource); + const result = template(data); + setRenderedTemplate(result); + }, [templateSource, data]); + + if (renderedTemplate) { + return ; + } + return

Loading...

; +}; diff --git a/plugins/plugin-chart-handlebars/src/consts.ts b/plugins/plugin-chart-handlebars/src/consts.ts new file mode 100644 index 0000000000..e6b215ede3 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/consts.ts @@ -0,0 +1,37 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { formatSelectOptions } from '@superset-ui/chart-controls'; +import { addLocaleData, t } from '@superset-ui/core'; +import i18n from './i18n'; + +addLocaleData(i18n); + +export const PAGE_SIZE_OPTIONS = formatSelectOptions([ + [0, t('page_size.all')], + 1, + 2, + 3, + 4, + 5, + 10, + 20, + 50, + 100, + 200, +]); diff --git a/plugins/plugin-chart-handlebars/src/i18n.ts b/plugins/plugin-chart-handlebars/src/i18n.ts new file mode 100644 index 0000000000..e8e1657f1d --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/i18n.ts @@ -0,0 +1,47 @@ +import { Locale } from '@superset-ui/core'; + +const en = { + 'Query Mode': [''], + Aggregate: [''], + 'Raw Records': [''], + 'Emit Filter Events': [''], + 'Show Cell Bars': [''], + 'page_size.show': ['Show'], + 'page_size.all': ['All'], + 'page_size.entries': ['entries'], + 'table.previous_page': ['Previous'], + 'table.next_page': ['Next'], + 'search.num_records': ['%s record', '%s records...'], +}; + +const translations: Partial> = { + en, + fr: { + 'Query Mode': [''], + Aggregate: [''], + 'Raw Records': [''], + 'Emit Filter Events': [''], + 'Show Cell Bars': [''], + 'page_size.show': ['Afficher'], + 'page_size.all': ['tous'], + 'page_size.entries': ['entrées'], + 'table.previous_page': ['Précédent'], + 'table.next_page': ['Suivante'], + 'search.num_records': ['%s enregistrement', '%s enregistrements...'], + }, + zh: { + 'Query Mode': ['查询模式'], + Aggregate: ['分组聚合'], + 'Raw Records': ['原始数据'], + 'Emit Filter Events': ['关联看板过滤器'], + 'Show Cell Bars': ['为指标添加条状图背景'], + 'page_size.show': ['每页显示'], + 'page_size.all': ['全部'], + 'page_size.entries': ['条'], + 'table.previous_page': ['上一页'], + 'table.next_page': ['下一页'], + 'search.num_records': ['%s条记录...'], + }, +}; + +export default translations; diff --git a/plugins/plugin-chart-handlebars/src/images/thumbnail.png b/plugins/plugin-chart-handlebars/src/images/thumbnail.png new file mode 100644 index 0000000000..342bc23206 Binary files /dev/null and b/plugins/plugin-chart-handlebars/src/images/thumbnail.png differ diff --git a/plugins/plugin-chart-handlebars/src/index.ts b/plugins/plugin-chart-handlebars/src/index.ts new file mode 100644 index 0000000000..c39fe12b95 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/index.ts @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +// eslint-disable-next-line import/prefer-default-export +export { default as HandlebarsChartPlugin } from './plugin'; +/** + * Note: this file exports the default export from Handlebars.tsx. + * If you want to export multiple visualization modules, you will need to + * either add additional plugin folders (similar in structure to ./plugin) + * OR export multiple instances of `ChartPlugin` extensions in ./plugin/index.ts + * which in turn load exports from Handlebars.tsx + */ diff --git a/plugins/plugin-chart-handlebars/src/plugin/buildQuery.ts b/plugins/plugin-chart-handlebars/src/plugin/buildQuery.ts new file mode 100644 index 0000000000..43ab986d30 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/buildQuery.ts @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { buildQueryContext, QueryFormData } from '@superset-ui/core'; + +export default function buildQuery(formData: QueryFormData) { + const { metric, sort_by_metric } = formData; + + return buildQueryContext(formData, baseQueryObject => [ + { + ...baseQueryObject, + ...(sort_by_metric && { orderby: [[metric, false]] }), + }, + ]); +} diff --git a/plugins/plugin-chart-handlebars/src/plugin/controlPanel.tsx b/plugins/plugin-chart-handlebars/src/plugin/controlPanel.tsx new file mode 100644 index 0000000000..16511cb34c --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controlPanel.tsx @@ -0,0 +1,158 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + ControlPanelConfig, + emitFilterControl, + sections, +} from '@superset-ui/chart-controls'; +import { addLocaleData, t } from '@superset-ui/core'; +import i18n from '../i18n'; +import { AllColumnsControlSetItem } from './controls/columns'; +import { GroupByControlSetItem } from './controls/groupBy'; +import { HandlbarsTemplateControlSetItem } from './controls/handlebarTemplate'; +import { IncludeTimeControlSetItem } from './controls/includeTime'; +import { + RowLimitControlSetItem, + TimeSeriesLimitMetricControlSetItem, +} from './controls/limits'; +import { + MetricsControlSetItem, + PercentMetricsControlSetItem, + ShowTotalsControlSetItem, +} from './controls/metrics'; +import { + OrderByControlSetItem, + OrderDescendingControlSetItem, +} from './controls/orderBy'; +import { + ServerPageLengthControlSetItem, + ServerPaginationControlSetRow, +} from './controls/pagination'; +import { QueryModeControlSetItem } from './controls/queryMode'; +import { StyleControlSetItem } from './controls/style'; + +addLocaleData(i18n); + +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: [ + sections.legacyTimeseriesTime, + { + label: t('Query'), + expanded: true, + controlSetRows: [ + [QueryModeControlSetItem], + [GroupByControlSetItem], + [MetricsControlSetItem, AllColumnsControlSetItem], + [PercentMetricsControlSetItem], + [TimeSeriesLimitMetricControlSetItem, OrderByControlSetItem], + ServerPaginationControlSetRow, + [RowLimitControlSetItem, ServerPageLengthControlSetItem], + [IncludeTimeControlSetItem, OrderDescendingControlSetItem], + [ShowTotalsControlSetItem], + ['adhoc_filters'], + emitFilterControl, + ], + }, + { + label: t('Options'), + expanded: true, + controlSetRows: [ + [HandlbarsTemplateControlSetItem], + [StyleControlSetItem], + ], + }, + ], +}; + +export default config; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx b/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx new file mode 100644 index 0000000000..60dc573ddb --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/columns.tsx @@ -0,0 +1,67 @@ +import { + ColumnOption, + ControlSetItem, + ExtraControlProps, + sharedControls, +} from '@superset-ui/chart-controls'; +import { + ensureIsArray, + FeatureFlag, + isFeatureEnabled, + t, +} from '@superset-ui/core'; +import React from 'react'; +import { getQueryMode, isRawMode } from './shared'; + +export const all_columns: typeof sharedControls.groupby = { + type: 'SelectControl', + label: t('Columns'), + description: t('Columns to display'), + multi: true, + freeForm: true, + allowAll: true, + commaChoosesOption: false, + default: [], + optionRenderer: c => , + valueRenderer: c => , + valueKey: 'column_name', + mapStateToProps: ({ datasource, controls }, controlState) => ({ + options: datasource?.columns || [], + queryMode: getQueryMode(controls), + externalValidationErrors: + isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0 + ? [t('must have a value')] + : [], + }), + visibility: isRawMode, +}; + +export const dnd_all_columns: typeof sharedControls.groupby = { + type: 'DndColumnSelect', + label: t('Columns'), + description: t('Columns to display'), + default: [], + mapStateToProps({ datasource, controls }, controlState) { + const newState: ExtraControlProps = {}; + if (datasource) { + const options = datasource.columns; + newState.options = Object.fromEntries( + options.map(option => [option.column_name, option]), + ); + } + newState.queryMode = getQueryMode(controls); + newState.externalValidationErrors = + isRawMode({ controls }) && ensureIsArray(controlState.value).length === 0 + ? [t('must have a value')] + : []; + return newState; + }, + visibility: isRawMode, +}; + +export const AllColumnsControlSetItem: ControlSetItem = { + name: 'all_columns', + config: isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP) + ? dnd_all_columns + : all_columns, +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/groupBy.tsx b/plugins/plugin-chart-handlebars/src/plugin/controls/groupBy.tsx new file mode 100644 index 0000000000..5e27f911b7 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/groupBy.tsx @@ -0,0 +1,27 @@ +import { + ControlPanelState, + ControlSetItem, + ControlState, + sharedControls, +} from '@superset-ui/chart-controls'; +import { isAggMode, validateAggControlValues } from './shared'; + +export const GroupByControlSetItem: ControlSetItem = { + name: 'groupby', + override: { + visibility: isAggMode, + mapStateToProps: (state: ControlPanelState, controlState: ControlState) => { + const { controls } = state; + const originalMapStateToProps = sharedControls?.groupby?.mapStateToProps; + const newState = originalMapStateToProps?.(state, controlState) ?? {}; + newState.externalValidationErrors = validateAggControlValues(controls, [ + controls.metrics?.value, + controls.percent_metrics?.value, + controlState.value, + ]); + + return newState; + }, + rerender: ['metrics', 'percent_metrics'], + }, +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/handlebarTemplate.tsx b/plugins/plugin-chart-handlebars/src/plugin/controls/handlebarTemplate.tsx new file mode 100644 index 0000000000..30bab34c33 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/handlebarTemplate.tsx @@ -0,0 +1,60 @@ +import { + ControlConfig, + ControlSetItem, + CustomControlConfig, + sharedControls, +} from '@superset-ui/chart-controls'; +import { t, validateNonEmpty } from '@superset-ui/core'; +import React from 'react'; +import { CodeEditor } from '../../components/CodeEditor/CodeEditor'; +import { ControlHeader } from '../../components/ControlHeader/controlHeader'; + +interface HandlebarsCustomControlProps { + value: string; +} + +const HandlbarsTemplateControl = ( + props: CustomControlConfig, +) => { + const val = String( + props?.value ? props?.value : props?.default ? props?.default : '', + ); + + const updateConfig = (source: string) => { + props.onChange(source); + }; + return ( +
+ {props.label} + { + updateConfig(source || ''); + }} + /> +
+ ); +}; +const handlebarsTemplateControlConfig: ControlConfig = { + ...sharedControls.entity, + type: HandlbarsTemplateControl, + label: t('Handlebars Template'), + description: t('A handlebars template that is applied to the data'), + default: `
    + {{#each data}} +
  • {{this}}
  • + {{/each}} +
`, + isInt: false, + + validators: [validateNonEmpty], + mapStateToProps: ({ controls }) => ({ + value: controls?.handlebars_template?.value, + }), +}; + +export const HandlbarsTemplateControlSetItem: ControlSetItem = { + name: 'handlebarsTemplate', + config: handlebarsTemplateControlConfig, +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/includeTime.ts b/plugins/plugin-chart-handlebars/src/plugin/controls/includeTime.ts new file mode 100644 index 0000000000..8cbc377dd4 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/includeTime.ts @@ -0,0 +1,16 @@ +import { ControlSetItem } from '@superset-ui/chart-controls'; +import { t } from '@superset-ui/core'; +import { isAggMode } from './shared'; + +export const IncludeTimeControlSetItem: ControlSetItem = { + name: 'include_time', + config: { + type: 'CheckboxControl', + label: t('Include time'), + description: t( + 'Whether to include the time granularity as defined in the time section', + ), + default: false, + visibility: isAggMode, + }, +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/limits.ts b/plugins/plugin-chart-handlebars/src/plugin/controls/limits.ts new file mode 100644 index 0000000000..718268d097 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/limits.ts @@ -0,0 +1,20 @@ +import { + ControlPanelsContainerProps, + ControlSetItem, +} from '@superset-ui/chart-controls'; +import { isAggMode } from './shared'; + +export const RowLimitControlSetItem: ControlSetItem = { + name: 'row_limit', + override: { + visibility: ({ controls }: ControlPanelsContainerProps) => + !controls?.server_pagination?.value, + }, +}; + +export const TimeSeriesLimitMetricControlSetItem: ControlSetItem = { + name: 'timeseries_limit_metric', + override: { + visibility: isAggMode, + }, +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx b/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx new file mode 100644 index 0000000000..cd85118641 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/metrics.tsx @@ -0,0 +1,85 @@ +import { + ControlPanelState, + ControlSetItem, + ControlState, + sharedControls, +} from '@superset-ui/chart-controls'; +import { FeatureFlag, isFeatureEnabled, t } from '@superset-ui/core'; +import { getQueryMode, isAggMode, validateAggControlValues } from './shared'; + +const percent_metrics: typeof sharedControls.metrics = { + type: 'MetricsControl', + label: t('Percentage metrics'), + description: t( + 'Metrics for which percentage of total are to be displayed. Calculated from only data within the row limit.', + ), + multi: true, + visibility: isAggMode, + mapStateToProps: ({ datasource, controls }, controlState) => ({ + columns: datasource?.columns || [], + savedMetrics: datasource?.metrics || [], + datasource, + datasourceType: datasource?.type, + queryMode: getQueryMode(controls), + externalValidationErrors: validateAggControlValues(controls, [ + controls.groupby?.value, + controls.metrics?.value, + controlState.value, + ]), + }), + rerender: ['groupby', 'metrics'], + default: [], + validators: [], +}; + +const dnd_percent_metrics = { + ...percent_metrics, + type: 'DndMetricSelect', +}; + +export const PercentMetricsControlSetItem: ControlSetItem = { + name: 'percent_metrics', + config: { + ...(isFeatureEnabled(FeatureFlag.ENABLE_EXPLORE_DRAG_AND_DROP) + ? dnd_percent_metrics + : percent_metrics), + }, +}; + +export const MetricsControlSetItem: ControlSetItem = { + name: 'metrics', + override: { + validators: [], + visibility: isAggMode, + mapStateToProps: ( + { controls, datasource, form_data }: ControlPanelState, + controlState: ControlState, + ) => ({ + columns: datasource?.columns.filter(c => c.filterable) || [], + savedMetrics: datasource?.metrics || [], + // current active adhoc metrics + selectedMetrics: + form_data.metrics || (form_data.metric ? [form_data.metric] : []), + datasource, + externalValidationErrors: validateAggControlValues(controls, [ + controls.groupby?.value, + controls.percent_metrics?.value, + controlState.value, + ]), + }), + rerender: ['groupby', 'percent_metrics'], + }, +}; + +export const ShowTotalsControlSetItem: ControlSetItem = { + name: 'show_totals', + config: { + type: 'CheckboxControl', + label: t('Show totals'), + default: false, + description: t( + 'Show total aggregations of selected metrics. Note that row limit does not apply to the result.', + ), + visibility: isAggMode, + }, +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx b/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx new file mode 100644 index 0000000000..70cc27fba0 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/orderBy.tsx @@ -0,0 +1,29 @@ +import { ControlSetItem } from '@superset-ui/chart-controls'; +import { t } from '@superset-ui/core'; +import { isAggMode, isRawMode } from './shared'; + +export const OrderByControlSetItem: ControlSetItem = { + name: 'order_by_cols', + config: { + type: 'SelectControl', + label: t('Ordering'), + description: t('Order results by selected columns'), + multi: true, + default: [], + mapStateToProps: ({ datasource }) => ({ + choices: datasource?.order_by_choices || [], + }), + visibility: isRawMode, + }, +}; + +export const OrderDescendingControlSetItem: ControlSetItem = { + name: 'order_desc', + config: { + type: 'CheckboxControl', + label: t('Sort descending'), + default: true, + description: t('Whether to sort descending or ascending'), + visibility: isAggMode, + }, +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/pagination.tsx b/plugins/plugin-chart-handlebars/src/plugin/controls/pagination.tsx new file mode 100644 index 0000000000..e4db1ba7b4 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/pagination.tsx @@ -0,0 +1,39 @@ +import { + ControlPanelsContainerProps, + ControlSetItem, + ControlSetRow, +} from '@superset-ui/chart-controls'; +import { FeatureFlag, isFeatureEnabled, t } from '@superset-ui/core'; +import { PAGE_SIZE_OPTIONS } from '../../consts'; + +export const ServerPaginationControlSetRow: ControlSetRow = + isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS) || + isFeatureEnabled(FeatureFlag.DASHBOARD_NATIVE_FILTERS) + ? [ + { + name: 'server_pagination', + config: { + type: 'CheckboxControl', + label: t('Server pagination'), + description: t( + 'Enable server side pagination of results (experimental feature)', + ), + default: false, + }, + }, + ] + : []; + +export const ServerPageLengthControlSetItem: ControlSetItem = { + name: 'server_page_length', + config: { + type: 'SelectControl', + freeForm: true, + label: t('Server Page Length'), + default: 10, + choices: PAGE_SIZE_OPTIONS, + description: t('Rows per page, 0 means no pagination'), + visibility: ({ controls }: ControlPanelsContainerProps) => + Boolean(controls?.server_pagination?.value), + }, +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/queryMode.tsx b/plugins/plugin-chart-handlebars/src/plugin/controls/queryMode.tsx new file mode 100644 index 0000000000..0646195a34 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/queryMode.tsx @@ -0,0 +1,24 @@ +import { + ControlConfig, + ControlSetItem, + QueryModeLabel, +} from '@superset-ui/chart-controls'; +import { QueryMode, t } from '@superset-ui/core'; +import { getQueryMode } from './shared'; + +const queryMode: ControlConfig<'RadioButtonControl'> = { + type: 'RadioButtonControl', + label: t('Query mode'), + default: null, + options: [ + [QueryMode.aggregate, QueryModeLabel[QueryMode.aggregate]], + [QueryMode.raw, QueryModeLabel[QueryMode.raw]], + ], + mapStateToProps: ({ controls }) => ({ value: getQueryMode(controls) }), + rerender: ['all_columns', 'groupby', 'metrics', 'percent_metrics'], +}; + +export const QueryModeControlSetItem: ControlSetItem = { + name: 'query_mode', + config: queryMode, +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/shared.ts b/plugins/plugin-chart-handlebars/src/plugin/controls/shared.ts new file mode 100644 index 0000000000..f4b1ae6879 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/shared.ts @@ -0,0 +1,43 @@ +import { + ControlPanelsContainerProps, + ControlStateMapping, +} from '@superset-ui/chart-controls'; +import { + ensureIsArray, + QueryFormColumn, + QueryMode, + t, +} from '@superset-ui/core'; + +export function getQueryMode(controls: ControlStateMapping): QueryMode { + const mode = controls?.query_mode?.value; + if (mode === QueryMode.aggregate || mode === QueryMode.raw) { + return mode as QueryMode; + } + const rawColumns = controls?.all_columns?.value as + | QueryFormColumn[] + | undefined; + const hasRawColumns = rawColumns && rawColumns.length > 0; + return hasRawColumns ? QueryMode.raw : QueryMode.aggregate; +} + +/** + * Visibility check + */ +export function isQueryMode(mode: QueryMode) { + return ({ controls }: Pick) => + getQueryMode(controls) === mode; +} + +export const isAggMode = isQueryMode(QueryMode.aggregate); +export const isRawMode = isQueryMode(QueryMode.raw); + +export const validateAggControlValues = ( + controls: ControlStateMapping, + values: any[], +) => { + const areControlsEmpty = values.every(val => ensureIsArray(val).length === 0); + return areControlsEmpty && isAggMode({ controls }) + ? [t('Group By, Metrics or Percentage Metrics must have a value')] + : []; +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/controls/style.tsx b/plugins/plugin-chart-handlebars/src/plugin/controls/style.tsx new file mode 100644 index 0000000000..c460f30fb9 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/controls/style.tsx @@ -0,0 +1,55 @@ +import { + ControlConfig, + ControlSetItem, + CustomControlConfig, + sharedControls, +} from '@superset-ui/chart-controls'; +import { t } from '@superset-ui/core'; +import React from 'react'; +import { CodeEditor } from '../../components/CodeEditor/CodeEditor'; +import { ControlHeader } from '../../components/ControlHeader/controlHeader'; + +interface StyleCustomControlProps { + value: string; +} + +const StyleControl = (props: CustomControlConfig) => { + const val = String( + props?.value ? props?.value : props?.default ? props?.default : '', + ); + + const updateConfig = (source: string) => { + props.onChange(source); + }; + return ( +
+ {props.label} + { + updateConfig(source || ''); + }} + /> +
+ ); +}; +const styleControlConfig: ControlConfig = { + ...sharedControls.entity, + type: StyleControl, + label: t('CSS Styles'), + description: t('CSS applied to the chart'), + default: '', + isInt: false, + + validators: [], + mapStateToProps: ({ controls }) => ({ + value: controls?.handlebars_template?.value, + }), +}; + +export const StyleControlSetItem: ControlSetItem = { + name: 'styleTemplate', + config: styleControlConfig, +}; diff --git a/plugins/plugin-chart-handlebars/src/plugin/index.ts b/plugins/plugin-chart-handlebars/src/plugin/index.ts new file mode 100644 index 0000000000..db5ad528f8 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/index.ts @@ -0,0 +1,51 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ChartMetadata, ChartPlugin, t } from '@superset-ui/core'; +import thumbnail from '../images/thumbnail.png'; +import buildQuery from './buildQuery'; +import controlPanel from './controlPanel'; +import transformProps from './transformProps'; + +export default class HandlebarsChartPlugin extends ChartPlugin { + /** + * The constructor is used to pass relevant metadata and callbacks that get + * registered in respective registries that are used throughout the library + * and application. A more thorough description of each property is given in + * the respective imported file. + * + * It is worth noting that `buildQuery` and is optional, and only needed for + * advanced visualizations that require either post processing operations + * (pivoting, rolling aggregations, sorting etc) or submitting multiple queries. + */ + constructor() { + const metadata = new ChartMetadata({ + description: 'Write a handlebars template to render the data', + name: t('Handlebars'), + thumbnail, + }); + + super({ + buildQuery, + controlPanel, + loadChart: () => import('../Handlebars'), + metadata, + transformProps, + }); + } +} diff --git a/plugins/plugin-chart-handlebars/src/plugin/transformProps.ts b/plugins/plugin-chart-handlebars/src/plugin/transformProps.ts new file mode 100644 index 0000000000..cb83e112d8 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/plugin/transformProps.ts @@ -0,0 +1,67 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ChartProps, TimeseriesDataRecord } from '@superset-ui/core'; + +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 data = queriesData[0].data as TimeseriesDataRecord[]; + + return { + width, + height, + + 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, + }; +} diff --git a/plugins/plugin-chart-handlebars/src/types.ts b/plugins/plugin-chart-handlebars/src/types.ts new file mode 100644 index 0000000000..bfe64fab40 --- /dev/null +++ b/plugins/plugin-chart-handlebars/src/types.ts @@ -0,0 +1,66 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ColumnConfig } from '@superset-ui/chart-controls'; +import { + QueryFormData, + QueryFormMetric, + QueryMode, + TimeGranularity, + TimeseriesDataRecord, +} from '@superset-ui/core'; + +export interface HandlebarsStylesProps { + height: number; + width: number; +} + +interface HandlebarsCustomizeProps { + headerText: string; + handlebarsTemplate?: string; + styleTemplate?: string; +} + +export type HandlebarsQueryFormData = QueryFormData & + HandlebarsStylesProps & + HandlebarsCustomizeProps & { + align_pn?: boolean; + color_pn?: boolean; + include_time?: boolean; + include_search?: boolean; + query_mode?: QueryMode; + page_length?: string | number | null; // null means auto-paginate + metrics?: QueryFormMetric[] | null; + percent_metrics?: QueryFormMetric[] | null; + timeseries_limit_metric?: QueryFormMetric[] | QueryFormMetric | null; + groupby?: QueryFormMetric[] | null; + all_columns?: QueryFormMetric[] | null; + order_desc?: boolean; + table_timestamp_format?: string; + emit_filter?: boolean; + granularity_sqla?: string; + time_grain_sqla?: TimeGranularity; + column_config?: Record; + }; + +export type HandlebarsProps = HandlebarsStylesProps & + HandlebarsCustomizeProps & { + data: TimeseriesDataRecord[]; + // add typing here for the props you pass in from transformProps.ts! + formData: HandlebarsQueryFormData; + }; diff --git a/plugins/plugin-chart-handlebars/test/index.test.ts b/plugins/plugin-chart-handlebars/test/index.test.ts new file mode 100644 index 0000000000..9121daeca4 --- /dev/null +++ b/plugins/plugin-chart-handlebars/test/index.test.ts @@ -0,0 +1,33 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { HandlebarsChartPlugin } from '../src'; + +/** + * The example tests in this file act as a starting point, and + * we encourage you to build more. These tests check that the + * plugin loads properly, and focus on `transformProps` + * to ake sure that data, controls, and props are all + * treated correctly (e.g. formData from plugin controls + * properly transform the data and/or any resulting props). + */ +describe('@superset-ui/plugin-chart-handlebars', () => { + it('exists', () => { + expect(HandlebarsChartPlugin).toBeDefined(); + }); +}); diff --git a/plugins/plugin-chart-handlebars/test/plugin/buildQuery.test.ts b/plugins/plugin-chart-handlebars/test/plugin/buildQuery.test.ts new file mode 100644 index 0000000000..33ca12b66d --- /dev/null +++ b/plugins/plugin-chart-handlebars/test/plugin/buildQuery.test.ts @@ -0,0 +1,16 @@ +import buildQuery from '../../src/plugin/buildQuery'; + +describe('Handlebars buildQuery', () => { + const formData = { + datasource: '5__table', + granularity_sqla: 'ds', + series: 'foo', + viz_type: 'my_chart', + }; + + it('should build groupby with series in form data', () => { + const queryContext = buildQuery(formData); + const [query] = queryContext.queries; + expect(query.groupby).toEqual(['foo']); + }); +}); diff --git a/plugins/plugin-chart-handlebars/test/plugin/transformProps.test.ts b/plugins/plugin-chart-handlebars/test/plugin/transformProps.test.ts new file mode 100644 index 0000000000..cf6d6cc111 --- /dev/null +++ b/plugins/plugin-chart-handlebars/test/plugin/transformProps.test.ts @@ -0,0 +1,38 @@ +import { ChartProps } from '@superset-ui/core'; +import transformProps from '../../src/plugin/transformProps'; + +describe('Handlebars tranformProps', () => { + const formData = { + colorScheme: 'bnbColors', + datasource: '3__table', + granularity_sqla: 'ds', + metric: 'sum__num', + series: 'name', + boldText: true, + headerFontSize: 'xs', + headerText: 'my text', + }; + const chartProps = new ChartProps({ + formData, + width: 800, + height: 600, + queriesData: [ + { + data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }], + }, + ], + }); + + it('should tranform chart props for viz', () => { + expect(transformProps(chartProps)).toEqual({ + width: 800, + height: 600, + boldText: true, + headerFontSize: 'xs', + headerText: 'my text', + data: [ + { name: 'Hulk', sum__num: 1, __timestamp: new Date(599616000000) }, + ], + }); + }); +}); diff --git a/plugins/plugin-chart-handlebars/tsconfig.json b/plugins/plugin-chart-handlebars/tsconfig.json new file mode 100644 index 0000000000..b6bfaa2d98 --- /dev/null +++ b/plugins/plugin-chart-handlebars/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "declarationDir": "lib", + "outDir": "lib", + "rootDir": "src" + }, + "exclude": [ + "lib", + "test" + ], + "extends": "../../tsconfig.json", + "include": [ + "src/**/*", + "types/**/*", + "../../types/**/*" + ], + "references": [ + { + "path": "../../packages/superset-ui-chart-controls" + }, + { + "path": "../../packages/superset-ui-core" + } + ] +} diff --git a/plugins/plugin-chart-handlebars/types/external.d.ts b/plugins/plugin-chart-handlebars/types/external.d.ts new file mode 100644 index 0000000000..0935dbbd80 --- /dev/null +++ b/plugins/plugin-chart-handlebars/types/external.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const value: any; + export default value; +}