feat(glyph): single-file chart definition pattern across all plugins

Introduce `defineChart()` — a declarative pattern that bundles metadata,
arguments (control-panel config), buildQuery, transform, and render into
a single chart-plugin file. Migrate every chart plugin to this pattern:

  * plugin-chart-echarts: Pie, Funnel, Gauge, Sankey, Waterfall,
    Histogram, Tree, Bubble, BoxPlot, Sunburst, Radar, Treemap, Graph,
    Heatmap, Gantt, BigNumber (Total, WithTrendline, PoP, Glyph demo),
    MixedTimeseries, and the Timeseries family (Generic, Scatter,
    SmoothLine, Step, Area, Line, Bar)
  * legacy-plugin-chart-*: calendar, horizon, chord, country-map,
    world-map, paired-t-test, parallel-coordinates, partition, rose,
    map-box
  * other plugins: handlebars, word-cloud, pivot-table, table,
    ag-grid-table, cartodiagram
  * legacy-preset-chart-nvd3: Bubble, Bullet, Compare, TimePivot
  * legacy-preset-chart-deckgl: Grid, Hex, Polygon, Scatter (single-file
    defineChart); Arc, Contour, Geojson, Heatmap, Path, Screengrid kept
    on the original multi-file ChartPlugin pattern pending follow-up

Glyph-core lives as @superset-ui/glyph-core (extracted package) and
provides: defineChart, ~14 argument types (Metric, Dimension, Select,
Checkbox, Text, Int, Slider, etc.), reusable presets (ShowLegend,
HeaderFontSize, Subtitle, etc.), cross-filter utilities
(extractCrossFilterProps, createSelectedValuesMap, isDataPointFiltered,
createLabelMap), and visibility-condition helpers
(resolveArgClass, getArgVisibleWhen, evaluateGlyphCondition).

Customize-tab rendering uses a new GlyphOptionsPanel — a native React
renderer that hybrids glyph args with additionalControls, with
inlined sharedControls in the Query section.

Imports are routed through @apache-superset/core subpath entrypoints
(/translation for t, /common for GenericDataType).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Evan Rusackas
2026-05-14 14:23:50 -07:00
parent 4e09889607
commit 2e16b8266a
385 changed files with 29627 additions and 45106 deletions

View File

@@ -1,49 +0,0 @@
/**
* 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 '@apache-superset/core/theme';
import { createRef } from 'react';
import { HandlebarsViewer } from './components/Handlebars/HandlebarsViewer';
import { HandlebarsProps, HandlebarsStylesProps } from './types';
const Styles = styled.div<HandlebarsStylesProps>`
padding: ${({ theme }) => theme.sizeUnit * 4}px;
border-radius: ${({ theme }) => theme.borderRadius}px;
height: ${({ height }) => height}px;
width: ${({ width }) => width}px;
overflow: auto;
`;
export default function Handlebars(props: HandlebarsProps) {
const { data, height, width, formData } = props;
const styleTemplateSource = formData.styleTemplate
? `<style>${formData.styleTemplate}</style>`
: '';
const handlebarTemplateSource = formData.handlebarsTemplate
? formData.handlebarsTemplate
: '{{data}}';
const templateSource = `${handlebarTemplateSource}\n${styleTemplateSource} `;
const rootElem = createRef<HTMLDivElement>();
return (
<Styles ref={rootElem} height={height} width={width}>
<HandlebarsViewer data={{ data }} templateSource={templateSource} />
</Styles>
);
}

View File

@@ -1,25 +0,0 @@
/**
* 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.
*/
export {
CodeEditor,
type CodeEditorProps,
type CodeEditorMode,
type CodeEditorTheme,
} from '@superset-ui/core/components';

View File

@@ -1,31 +0,0 @@
/**
* 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 { ReactNode } from 'react';
interface ControlHeaderProps {
children: ReactNode;
}
export const ControlHeader = ({
children,
}: ControlHeaderProps): JSX.Element => (
<div className="ControlHeader">
<div className="pull-left">{children}</div>
</div>
);

View File

@@ -1,118 +0,0 @@
/**
* 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 { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { SafeMarkdown } from '@superset-ui/core/components';
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
import Handlebars from 'handlebars';
import { useMemo, useState } from 'react';
import { isPlainObject } from 'lodash';
import Helpers from 'just-handlebars-helpers';
import HandlebarsGroupBy from 'handlebars-group-by';
export interface HandlebarsViewerProps {
templateSource: string;
data: any;
}
export const HandlebarsViewer = ({
templateSource,
data,
}: HandlebarsViewerProps) => {
const [renderedTemplate, setRenderedTemplate] = useState('');
const [error, setError] = useState('');
const appContainer = document.getElementById('app');
const { common } = JSON.parse(
appContainer?.getAttribute('data-bootstrap') || '{}',
);
const htmlSanitization = common?.conf?.HTML_SANITIZATION ?? true;
const htmlSchemaOverrides =
common?.conf?.HTML_SANITIZATION_SCHEMA_EXTENSIONS || {};
useMemo(() => {
try {
const template = Handlebars.compile(templateSource);
const result = template(data);
setRenderedTemplate(result);
setError('');
} catch (error) {
setRenderedTemplate('');
setError(error.message);
}
}, [templateSource, data]);
const Error = styled.pre`
white-space: pre-wrap;
`;
if (error) {
return <Error>{error}</Error>;
}
if (renderedTemplate) {
return (
<SafeMarkdown
source={renderedTemplate}
htmlSanitization={htmlSanitization}
htmlSchemaOverrides={htmlSchemaOverrides}
/>
);
}
return <p>{t('Loading...')}</p>;
};
// usage: {{ dateFormat my_date format="MMMM YYYY" }}
Handlebars.registerHelper('dateFormat', function (context, block) {
const f = block.hash.format || 'YYYY-MM-DD';
return dayjs(context).format(f);
});
// usage: {{ }}
Handlebars.registerHelper('stringify', (obj: any, obj2: any) => {
// calling without an argument
if (obj2 === undefined)
throw new Error('Please call with an object. Example: `stringify myObj`');
return isPlainObject(obj) ? JSON.stringify(obj) : String(obj);
});
Handlebars.registerHelper(
'formatNumber',
function (number: any, locale = 'en-US') {
if (typeof number !== 'number') {
return number;
}
return number.toLocaleString(locale);
},
);
// usage: {{parseJson jsonString}}
Handlebars.registerHelper('parseJson', (jsonString: string) => {
try {
return JSON.parse(jsonString);
} catch (error) {
if (error instanceof Error) {
error.message = `Invalid JSON string: ${error.message}`;
throw error;
}
throw new Error(`Invalid JSON string: ${String(error)}`);
}
});
Helpers.registerHelpers(Handlebars);
HandlebarsGroupBy.register(Handlebars);

View File

@@ -1,25 +0,0 @@
/**
* 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 { debounce } from 'lodash';
import { Constants } from '@superset-ui/core/components';
export const debounceFunc = debounce(
(func: (val: string) => void, source: string) => func(source),
Constants.SLOW_DEBOUNCE,
);

View File

@@ -1,27 +0,0 @@
/**
* 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
*/

View File

@@ -0,0 +1,676 @@
/**
* 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.
*/
/**
* Handlebars Chart - Glyph Pattern Implementation
*
* Renders data via a user-supplied Handlebars template with optional CSS.
* Supports both Aggregate and Raw query modes.
*/
import { t } from '@apache-superset/core/translation';
import { styled, useTheme } from '@apache-superset/core/theme';
import {
buildQueryContext,
ensureIsArray,
normalizeOrderBy,
QueryFormColumn,
QueryFormData,
QueryFormMetric,
QueryMode,
TimeGranularity,
TimeseriesDataRecord,
validateNonEmpty,
} from '@superset-ui/core';
import {
CodeEditor,
Constants,
InfoTooltip,
SafeMarkdown,
} from '@superset-ui/core/components';
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
import {
ColumnMeta,
ControlConfig,
ControlPanelState,
ControlPanelsContainerProps,
ControlSetItem,
ControlState,
ControlStateMapping,
CustomControlConfig,
Dataset,
defineSavedMetrics,
ExtraControlProps,
getStandardizedControls,
QueryModeLabel,
sharedControls,
} from '@superset-ui/chart-controls';
import { defineChart } from '@superset-ui/glyph-core';
import Handlebars from 'handlebars';
import { debounce, isEmpty, isPlainObject } from 'lodash';
import Helpers from 'just-handlebars-helpers';
import HandlebarsGroupBy from 'handlebars-group-by';
import { createRef, ReactNode, useMemo, useState } from 'react';
import thumbnail from './images/thumbnail.png';
import thumbnailDark from './images/thumbnail-dark.png';
import example1 from './images/example1.jpg';
import example1Dark from './images/example1-dark.jpg';
import example2 from './images/example2.jpg';
import example2Dark from './images/example2-dark.jpg';
// ─── Types ───────────────────────────────────────────────────────────────────
export type HandlebarsQueryFormData = QueryFormData & {
height: number;
width: number;
handlebarsTemplate?: string;
styleTemplate?: string;
align_pn?: boolean;
color_pn?: boolean;
include_time?: boolean;
include_search?: boolean;
query_mode?: QueryMode;
page_length?: string | number | null;
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;
granularitySqla?: string;
time_grain_sqla?: TimeGranularity;
};
type HandlebarsProps = {
height: number;
width: number;
data: TimeseriesDataRecord[];
formData: HandlebarsQueryFormData;
};
// ─── Helpers & Utilities ─────────────────────────────────────────────────────
const debounceFunc = debounce(
(func: (val: string) => void, source: string) => func(source),
Constants.SLOW_DEBOUNCE,
);
const ControlHeader = ({ children }: { children: ReactNode }) => (
<div className="ControlHeader">
<div className="pull-left">{children}</div>
</div>
);
// ─── Handlebars Template Engine Setup ────────────────────────────────────────
// usage: {{ dateFormat my_date format="MMMM YYYY" }}
Handlebars.registerHelper('dateFormat', function (context, block) {
const f = block.hash.format || 'YYYY-MM-DD';
return dayjs(context).format(f);
});
// usage: {{ stringify myObj }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Handlebars.registerHelper('stringify', (obj: any, obj2: any) => {
if (obj2 === undefined)
throw new Error('Please call with an object. Example: `stringify myObj`');
return isPlainObject(obj) ? JSON.stringify(obj) : String(obj);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Handlebars.registerHelper(
'formatNumber',
function (number: any, locale = 'en-US') {
if (typeof number !== 'number') return number;
return number.toLocaleString(locale);
},
);
// usage: {{parseJson jsonString}}
Handlebars.registerHelper('parseJson', (jsonString: string) => {
try {
return JSON.parse(jsonString);
} catch (error) {
if (error instanceof Error) {
error.message = `Invalid JSON string: ${error.message}`;
throw error;
}
throw new Error(`Invalid JSON string: ${String(error)}`);
}
});
Helpers.registerHelpers(Handlebars);
HandlebarsGroupBy.register(Handlebars);
// ─── HandlebarsViewer ────────────────────────────────────────────────────────
const TemplateError = styled.pre`
white-space: pre-wrap;
`;
function HandlebarsViewer({
templateSource,
data,
}: {
templateSource: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
}) {
const [renderedTemplate, setRenderedTemplate] = useState('');
const [error, setError] = useState('');
const appContainer = document.getElementById('app');
const { common } = JSON.parse(
appContainer?.getAttribute('data-bootstrap') || '{}',
);
const htmlSanitization = common?.conf?.HTML_SANITIZATION ?? true;
const htmlSchemaOverrides =
common?.conf?.HTML_SANITIZATION_SCHEMA_EXTENSIONS || {};
useMemo(() => {
try {
const template = Handlebars.compile(templateSource);
const result = template(data);
setRenderedTemplate(result);
setError('');
} catch (err) {
setRenderedTemplate('');
setError((err as Error).message);
}
}, [templateSource, data]);
if (error) return <TemplateError>{error}</TemplateError>;
if (renderedTemplate) {
return (
<SafeMarkdown
source={renderedTemplate}
htmlSanitization={htmlSanitization}
htmlSchemaOverrides={htmlSchemaOverrides}
/>
);
}
return <p>{t('Loading...')}</p>;
}
// ─── Render Component ────────────────────────────────────────────────────────
const HandlebarsStyles = styled.div<{ height: number; width: number }>`
padding: ${({ theme }) => theme.sizeUnit * 4}px;
border-radius: ${({ theme }) => theme.borderRadius}px;
height: ${({ height }) => height}px;
width: ${({ width }) => width}px;
overflow: auto;
`;
function HandlebarsChart(props: HandlebarsProps) {
const { data, height, width, formData } = props;
const styleTemplateSource = formData.styleTemplate
? `<style>${formData.styleTemplate}</style>`
: '';
const templateSource = `${formData.handlebarsTemplate || '{{data}}'}\n${styleTemplateSource} `;
const rootElem = createRef<HTMLDivElement>();
return (
<HandlebarsStyles ref={rootElem} height={height} width={width}>
<HandlebarsViewer data={{ data }} templateSource={templateSource} />
</HandlebarsStyles>
);
}
// ─── Query Mode Utilities ─────────────────────────────────────────────────────
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;
return rawColumns?.length ? QueryMode.Raw : QueryMode.Aggregate;
}
function isQueryMode(mode: QueryMode) {
return ({ controls }: Pick<ControlPanelsContainerProps, 'controls'>) =>
getQueryMode(controls) === mode;
}
const isAggMode = isQueryMode(QueryMode.Aggregate);
const isRawMode = isQueryMode(QueryMode.Raw);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function 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')]
: [];
}
// ─── Controls ─────────────────────────────────────────────────────────────────
const queryModeControlSetItem: ControlSetItem = {
name: 'query_mode',
config: {
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'],
} as ControlConfig<'RadioButtonControl'>,
};
const groupByControlSetItem: ControlSetItem = {
name: 'groupby',
override: {
visibility: isAggMode,
resetOnHide: false,
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'],
},
};
const allColumnsControlSetItem: ControlSetItem = {
name: 'all_columns',
config: {
type: 'DndColumnSelect',
label: t('Columns'),
description: t('Columns to display'),
default: [],
mapStateToProps({ datasource, controls }, controlState) {
const newState: ExtraControlProps = {};
if (datasource) {
if (datasource?.columns[0]?.hasOwnProperty('filterable')) {
newState.options = (datasource as Dataset)?.columns?.filter(
(c: ColumnMeta) => c.filterable,
);
} else {
newState.options = datasource.columns;
}
}
newState.queryMode = getQueryMode(controls);
newState.externalValidationErrors =
isRawMode({ controls }) &&
ensureIsArray(controlState?.value).length === 0
? [t('must have a value')]
: [];
return newState;
},
visibility: isRawMode,
resetOnHide: false,
} as typeof sharedControls.groupby,
};
const metricsControlSetItem: ControlSetItem = {
name: 'metrics',
override: {
validators: [],
visibility: isAggMode,
mapStateToProps: (
{ controls, datasource, form_data }: ControlPanelState,
controlState: ControlState,
) => ({
columns: datasource?.columns[0]?.hasOwnProperty('filterable')
? (datasource as Dataset)?.columns?.filter(
(c: ColumnMeta) => c.filterable,
)
: datasource?.columns,
savedMetrics: defineSavedMetrics(datasource),
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'],
resetOnHide: false,
},
};
const percentMetricsControlSetItem: ControlSetItem = {
name: 'percent_metrics',
config: {
type: 'DndMetricSelect',
label: t('Percentage metrics'),
description: t(
'Select one or many metrics to display, that will be displayed in the percentages of total. ' +
'Percentage metrics will be calculated only from data within the row limit. ' +
'You can use an aggregation function on a column or write custom SQL to create a percentage metric.',
),
multi: true,
visibility: isAggMode,
resetOnHide: false,
mapStateToProps: ({ datasource, controls }, controlState) => ({
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
datasource,
datasourceType: datasource?.type,
queryMode: getQueryMode(controls),
externalValidationErrors: validateAggControlValues(controls, [
controls.groupby?.value,
controls.metrics?.value,
controlState?.value,
]),
}),
rerender: ['groupby', 'metrics'],
default: [],
validators: [],
},
};
const showTotalsControlSetItem: ControlSetItem = {
name: 'show_totals',
config: {
type: 'CheckboxControl',
label: t('Show summary'),
default: false,
description: t(
'Show total aggregations of selected metrics. Note that row limit does not apply to the result.',
),
visibility: isAggMode,
resetOnHide: false,
},
};
const rowLimitControlSetItem: ControlSetItem = {
name: 'row_limit',
override: {
visibility: ({ controls }: ControlPanelsContainerProps) =>
!controls?.server_pagination?.value,
},
};
const timeSeriesLimitMetricControlSetItem: ControlSetItem = {
name: 'timeseries_limit_metric',
override: {
visibility: isAggMode,
resetOnHide: false,
},
};
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?.hasOwnProperty('order_by_choices')
? (datasource as Dataset)?.order_by_choices
: datasource?.columns || [],
}),
visibility: isRawMode,
resetOnHide: false,
},
};
const orderDescendingControlSetItem: ControlSetItem = {
name: 'order_desc',
config: {
type: 'CheckboxControl',
label: t('Sort descending'),
default: true,
description: t('Whether to sort descending or ascending'),
visibility: ({ controls }) =>
!!(
isAggMode({ controls }) &&
controls?.timeseries_limit_metric.value &&
!isEmpty(controls?.timeseries_limit_metric.value)
),
resetOnHide: false,
},
};
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,
resetOnHide: false,
},
};
// ─── Code Editor Controls ─────────────────────────────────────────────────────
const HandlebarsTemplateControl = (
props: CustomControlConfig<{ value: string }>,
) => {
const theme = useTheme();
const val = String(props?.value || props?.default || '');
const helperDescriptionsHeader = t(
'Available Handlebars Helpers in Superset:',
);
const helperDescriptions = [
{ key: 'dateFormat', descKey: 'Formats a date using a specified format.' },
{ key: 'stringify', descKey: 'Converts an object to a JSON string.' },
{
key: 'formatNumber',
descKey: 'Formats a number using locale-specific formatting.',
},
{
key: 'parseJson',
descKey: 'Parses a JSON string into a JavaScript object.',
},
];
const helpersTooltipContent = `
${helperDescriptionsHeader}
${helperDescriptions
.map(({ key, descKey }) => `- **${key}**: ${t(descKey)}`)
.join('\n')}
`;
return (
<div>
<ControlHeader>
<div>
{props.label as ReactNode}
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={<SafeMarkdown source={helpersTooltipContent} />}
/>
</div>
</ControlHeader>
<CodeEditor
theme="dark"
value={val}
onChange={source => {
debounceFunc(props.onChange, source || '');
}}
/>
</div>
);
};
const handlebarsTemplateControlSetItem: ControlSetItem = {
name: 'handlebarsTemplate',
config: {
...sharedControls.entity,
type: HandlebarsTemplateControl,
label: t('Handlebars Template'),
description: t('A handlebars template that is applied to the data'),
default: `<ul class="data-list">
{{#each data}}
<li>{{stringify this}}</li>
{{/each}}
</ul>`,
isInt: false,
renderTrigger: true,
valueKey: null,
validators: [validateNonEmpty],
mapStateToProps: ({ controls }) => ({
value: controls?.handlebars_template?.value,
}),
},
};
const StyleControl = (props: CustomControlConfig<{ value: string }>) => {
const theme = useTheme();
const defaultValue = props?.value
? undefined
: `/*
.data-list {
background-color: yellow;
}
*/`;
return (
<div>
<ControlHeader>
<div>
{props.label as ReactNode}
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={t('You need to configure HTML sanitization to use CSS')}
/>
</div>
</ControlHeader>
<CodeEditor
theme="dark"
mode="css"
value={props.value}
defaultValue={defaultValue}
onChange={source => {
debounceFunc(props.onChange, source || '');
}}
/>
</div>
);
};
const styleControlSetItem: ControlSetItem = {
name: 'styleTemplate',
config: {
...sharedControls.entity,
type: StyleControl,
label: t('CSS Styles'),
description: t('CSS applied to the chart'),
isInt: false,
renderTrigger: true,
valueKey: null,
validators: [],
mapStateToProps: ({ controls }) => ({
value: controls?.handlebars_template?.value,
}),
},
};
// ─── Plugin Definition ────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HandlebarsExtra = Record<string, any>;
// Standalone exports for testing
export function buildQuery(formData: QueryFormData) {
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
orderby: normalizeOrderBy(baseQueryObject).orderby,
},
]);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function transformProps(chartProps: any) {
const { formData, queriesData, width, height } = chartProps;
const { data } = queriesData[0];
return { width, height, data, formData };
}
export default defineChart<Record<string, never>, HandlebarsExtra>({
metadata: {
name: t('Handlebars'),
description: t('Write a handlebars template to render the data'),
thumbnail,
thumbnailDark,
exampleGallery: [
{ url: example1, urlDark: example1Dark },
{ url: example2, urlDark: example2Dark },
],
},
arguments: {},
buildQuery: (formData: QueryFormData) =>
buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
orderby: normalizeOrderBy(baseQueryObject).orderby,
},
]),
suppressQuerySection: true,
prependSections: [
{
label: t('Query'),
expanded: true,
controlSetRows: [
[queryModeControlSetItem],
[groupByControlSetItem],
[metricsControlSetItem, allColumnsControlSetItem],
[percentMetricsControlSetItem],
[timeSeriesLimitMetricControlSetItem, orderByControlSetItem],
[orderDescendingControlSetItem],
[rowLimitControlSetItem],
[includeTimeControlSetItem],
[showTotalsControlSetItem],
['adhoc_filters'],
],
},
{
label: t('Options'),
expanded: true,
controlSetRows: [
[handlebarsTemplateControlSetItem],
[styleControlSetItem],
],
},
],
formDataOverrides: formData => ({
...formData,
groupby: getStandardizedControls().popAllColumns(),
metrics: getStandardizedControls().popAllMetrics(),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
render: (props: any) => <HandlebarsChart {...props} />,
});

View File

@@ -1,32 +0,0 @@
/**
* 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,
normalizeOrderBy,
QueryFormData,
} from '@superset-ui/core';
export default function buildQuery(formData: QueryFormData) {
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
orderby: normalizeOrderBy(baseQueryObject).orderby,
},
]);
}

View File

@@ -1,78 +0,0 @@
/**
* 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,
getStandardizedControls,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { allColumnsControlSetItem } from './controls/columns';
import { groupByControlSetItem } from './controls/groupBy';
import { handlebarsTemplateControlSetItem } 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 { queryModeControlSetItem } from './controls/queryMode';
import { styleControlSetItem } from './controls/style';
const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Query'),
expanded: true,
controlSetRows: [
[queryModeControlSetItem],
[groupByControlSetItem],
[metricsControlSetItem, allColumnsControlSetItem],
[percentMetricsControlSetItem],
[timeSeriesLimitMetricControlSetItem, orderByControlSetItem],
[orderDescendingControlSetItem],
[rowLimitControlSetItem],
[includeTimeControlSetItem],
[showTotalsControlSetItem],
['adhoc_filters'],
],
},
{
label: t('Options'),
expanded: true,
controlSetRows: [
[handlebarsTemplateControlSetItem],
[styleControlSetItem],
],
},
],
formDataOverrides: formData => ({
...formData,
groupby: getStandardizedControls().popAllColumns(),
metrics: getStandardizedControls().popAllMetrics(),
}),
};
export default config;

View File

@@ -1,58 +0,0 @@
/**
* 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 {
ControlSetItem,
ExtraControlProps,
sharedControls,
Dataset,
ColumnMeta,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { ensureIsArray } from '@superset-ui/core';
import { getQueryMode, isRawMode } from './shared';
const dndAllColumns: typeof sharedControls.groupby = {
type: 'DndColumnSelect',
label: t('Columns'),
description: t('Columns to display'),
default: [],
mapStateToProps({ datasource, controls }, controlState) {
const newState: ExtraControlProps = {};
if (datasource) {
if (datasource?.columns[0]?.hasOwnProperty('filterable')) {
newState.options = (datasource as Dataset)?.columns?.filter(
(c: ColumnMeta) => c.filterable,
);
} else newState.options = datasource.columns;
}
newState.queryMode = getQueryMode(controls);
newState.externalValidationErrors =
isRawMode({ controls }) && ensureIsArray(controlState?.value).length === 0
? [t('must have a value')]
: [];
return newState;
},
visibility: isRawMode,
resetOnHide: false,
};
export const allColumnsControlSetItem: ControlSetItem = {
name: 'all_columns',
config: dndAllColumns,
};

View File

@@ -1,45 +0,0 @@
/**
* 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 {
ControlPanelState,
ControlSetItem,
ControlState,
sharedControls,
} from '@superset-ui/chart-controls';
import { isAggMode, validateAggControlValues } from './shared';
export const groupByControlSetItem: ControlSetItem = {
name: 'groupby',
override: {
visibility: isAggMode,
resetOnHide: false,
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'],
},
};

View File

@@ -1,98 +0,0 @@
/**
* 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 {
ControlSetItem,
CustomControlConfig,
sharedControls,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { validateNonEmpty } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { InfoTooltip } from '@superset-ui/core/components';
import { CodeEditor } from '../../components/CodeEditor/CodeEditor';
import { ControlHeader } from '../../components/ControlHeader/controlHeader';
import { debounceFunc } from '../../consts';
interface HandlebarsCustomControlProps {
value: string;
}
const HandlebarsTemplateControl = (
props: CustomControlConfig<HandlebarsCustomControlProps>,
) => {
const theme = useTheme();
const val = String(
props?.value ? props?.value : props?.default ? props?.default : '',
);
return (
<div>
<ControlHeader>
<div>
{typeof props.label === 'function' ? null : props.label}
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={
<span>
{t('See ')}{' '}
<a
href="https://superset.apache.org/docs/using-superset/handlebars-chart"
target="_blank"
rel="noopener noreferrer"
>
{t('the Handlebars chart documentation')}
</a>{' '}
{t('for a list of available helpers.')}
</span>
}
/>
</div>
</ControlHeader>
<CodeEditor
theme="dark"
value={val}
onChange={source => {
debounceFunc(props.onChange, source || '');
}}
/>
</div>
);
};
export const handlebarsTemplateControlSetItem: ControlSetItem = {
name: 'handlebarsTemplate',
config: {
...sharedControls.entity,
type: HandlebarsTemplateControl,
label: t('Handlebars Template'),
description: t('A handlebars template that is applied to the data'),
default: `<ul class="data-list">
{{#each data}}
<li>{{stringify this}}</li>
{{/each}}
</ul>`,
isInt: false,
renderTrigger: true,
valueKey: null,
validators: [validateNonEmpty],
mapStateToProps: ({ form_data }) => ({
value: form_data?.handlebarsTemplate ?? form_data?.handlebars_template,
}),
},
};

View File

@@ -1,35 +0,0 @@
/**
* 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 { ControlSetItem } from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
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,
resetOnHide: false,
},
};

View File

@@ -1,39 +0,0 @@
/**
* 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 {
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,
resetOnHide: false,
},
};

View File

@@ -1,113 +0,0 @@
/**
* 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 {
ControlPanelState,
ControlSetItem,
ControlState,
sharedControls,
Dataset,
ColumnMeta,
defineSavedMetrics,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { getQueryMode, isAggMode, validateAggControlValues } from './shared';
const percentMetrics: typeof sharedControls.metrics = {
type: 'MetricsControl',
label: t('Percentage metrics'),
description: t(
'Select one or many metrics to display, that will be displayed in the percentages of total. ' +
'Percentage metrics will be calculated only from data within the row limit. ' +
'You can use an aggregation function on a column or write custom SQL to create a percentage metric.',
),
multi: true,
visibility: isAggMode,
resetOnHide: false,
mapStateToProps: ({ datasource, controls }, controlState) => ({
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
datasource,
datasourceType: datasource?.type,
queryMode: getQueryMode(controls),
externalValidationErrors: validateAggControlValues(controls, [
controls.groupby?.value,
controls.metrics?.value,
controlState?.value,
]),
}),
rerender: ['groupby', 'metrics'],
default: [],
validators: [],
};
const dndPercentMetrics = {
...percentMetrics,
type: 'DndMetricSelect',
};
export const percentMetricsControlSetItem: ControlSetItem = {
name: 'percent_metrics',
config: {
...dndPercentMetrics,
},
};
export const metricsControlSetItem: ControlSetItem = {
name: 'metrics',
override: {
validators: [],
visibility: isAggMode,
mapStateToProps: (
{ controls, datasource, form_data }: ControlPanelState,
controlState: ControlState,
) => ({
columns: datasource?.columns[0]?.hasOwnProperty('filterable')
? (datasource as Dataset)?.columns?.filter(
(c: ColumnMeta) => c.filterable,
)
: datasource?.columns,
savedMetrics: defineSavedMetrics(datasource),
// 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'],
resetOnHide: false,
},
};
export const showTotalsControlSetItem: ControlSetItem = {
name: 'show_totals',
config: {
type: 'CheckboxControl',
label: t('Show summary'),
default: false,
description: t(
'Show total aggregations of selected metrics. Note that row limit does not apply to the result.',
),
visibility: isAggMode,
resetOnHide: false,
},
};

View File

@@ -1,57 +0,0 @@
/**
* 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 { ControlSetItem, Dataset } from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { isEmpty } from 'lodash';
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?.hasOwnProperty('order_by_choices')
? (datasource as Dataset)?.order_by_choices
: datasource?.columns || [],
}),
visibility: isRawMode,
resetOnHide: false,
},
};
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: ({ controls }) =>
!!(
isAggMode({ controls }) &&
controls?.timeseries_limit_metric.value &&
!isEmpty(controls?.timeseries_limit_metric.value)
),
resetOnHide: false,
},
};

View File

@@ -1,43 +0,0 @@
/**
* 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 {
ControlConfig,
ControlSetItem,
QueryModeLabel,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { QueryMode } 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,
};

View File

@@ -1,57 +0,0 @@
/**
* 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 {
ControlPanelsContainerProps,
ControlStateMapping,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { ensureIsArray, QueryFormColumn, QueryMode } 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<ControlPanelsContainerProps, 'controls'>) =>
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')]
: [];
};

View File

@@ -1,95 +0,0 @@
/**
* 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 {
ControlSetItem,
CustomControlConfig,
sharedControls,
} from '@superset-ui/chart-controls';
import { t } from '@apache-superset/core/translation';
import { useTheme } from '@apache-superset/core/theme';
import { InfoTooltip } from '@superset-ui/core/components';
import { CodeEditor } from '../../components/CodeEditor/CodeEditor';
import { ControlHeader } from '../../components/ControlHeader/controlHeader';
import { debounceFunc } from '../../consts';
interface StyleCustomControlProps {
value: string;
htmlSanitization: boolean;
}
const StyleControl = (props: CustomControlConfig<StyleCustomControlProps>) => {
const theme = useTheme();
const htmlSanitization = props.htmlSanitization ?? true;
const defaultValue = props?.value
? undefined
: `/*
.data-list {
background-color: yellow;
}
*/`;
return (
<div>
<ControlHeader>
<div>
{typeof props.label === 'function' ? null : props.label}
{htmlSanitization && (
<InfoTooltip
iconStyle={{ marginLeft: theme.sizeUnit }}
tooltip={t(
'CSS styles may be removed by server-side HTML sanitization. ' +
'If styles are not applying, ask your Superset administrator ' +
'to adjust the HTML sanitization configuration.',
)}
/>
)}
</div>
</ControlHeader>
<CodeEditor
theme="dark"
mode="css"
value={props.value}
defaultValue={defaultValue}
onChange={source => {
debounceFunc(props.onChange, source || '');
}}
/>
</div>
);
};
export const styleControlSetItem: ControlSetItem = {
name: 'styleTemplate',
config: {
...sharedControls.entity,
type: StyleControl,
label: t('CSS Styles'),
description: t('CSS applied to the chart'),
isInt: false,
renderTrigger: true,
valueKey: null,
validators: [],
mapStateToProps: ({ form_data, common }) => ({
value: form_data?.styleTemplate ?? form_data?.style_template,
htmlSanitization: common?.conf?.HTML_SANITIZATION ?? true,
}),
},
};

View File

@@ -1,62 +0,0 @@
/**
* 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 { t } from '@apache-superset/core/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
import thumbnail from '../images/thumbnail.png';
import thumbnailDark from '../images/thumbnail-dark.png';
import example1 from '../images/example1.jpg';
import example1Dark from '../images/example1-dark.jpg';
import example2 from '../images/example2.jpg';
import example2Dark from '../images/example2-dark.jpg';
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: t('Write a handlebars template to render the data'),
name: t('Handlebars'),
thumbnail,
thumbnailDark,
exampleGallery: [
{ url: example1, urlDark: example1Dark },
{ url: example2, urlDark: example2Dark },
],
});
super({
buildQuery,
controlPanel,
loadChart: () => import('../Handlebars'),
metadata,
transformProps,
});
}
}

View File

@@ -1,31 +0,0 @@
/**
* 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) {
const { width, height, formData, queriesData } = chartProps;
const data = queriesData[0].data as TimeseriesDataRecord[];
return {
width,
height,
data,
formData,
};
}

View File

@@ -1,268 +0,0 @@
/*
* 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 { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
import { HandlebarsChartPlugin } from '@superset-ui/plugin-chart-handlebars';
import { kpiData, leaderboardData, timelineData } from './data';
import { withResizableChartDemo } from '@storybook-shared';
const VIZ_TYPE = 'handlebars';
new HandlebarsChartPlugin().configure({ key: VIZ_TYPE }).register();
getChartTransformPropsRegistry().registerValue(
VIZ_TYPE,
(chartProps: {
width: number;
height: number;
formData: object;
queriesData: { data: unknown[] }[];
}) => {
const { width, height, formData, queriesData } = chartProps;
const { data } = queriesData[0];
return { width, height, data, formData };
},
);
// KPI Dashboard template - uses inline styles for Storybook compatibility
const kpiTemplate = `
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
{{#each data}}
<div style="background: linear-gradient(135deg, {{#if (eq status 'success')}}#11998e 0%, #38ef7d{{else}}{{#if (eq status 'warning')}}#f093fb 0%, #f5576c{{else}}#667eea 0%, #764ba2{{/if}}{{/if}} 100%); border-radius: 16px; padding: 20px; color: white; box-shadow: 0 10px 40px rgba(0,0,0,0.2);">
<div style="font-size: 32px; margin-bottom: 12px;">{{icon}}</div>
<div style="font-size: 12px; text-transform: uppercase; letter-spacing: 1px; opacity: 0.9;">{{metric}}</div>
<div style="font-size: 32px; font-weight: 700; margin: 4px 0;">{{formatNumber value}}</div>
<div style="display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 600; background: {{#if (gt change 0)}}rgba(255,255,255,0.2){{else}}rgba(0,0,0,0.15){{/if}};">
{{#if (gt change 0)}}<span>▲</span>{{else}}<span>▼</span>{{/if}}
{{change}}%
</div>
<div style="margin-top: 8px; font-size: 11px; opacity: 0.8;">Target: {{formatNumber target}}</div>
</div>
{{/each}}
</div>
`;
// Leaderboard template - dark theme with inline styles
const leaderboardTemplate = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; border-radius: 16px; padding: 24px; color: #eee;">
<h2 style="margin: 0 0 20px 0; font-size: 18px; color: #fff;">🏆 Top Performers</h2>
{{#each data}}
<div style="display: flex; align-items: center; padding: 12px 16px; margin: 8px 0; background: {{#if (eq rank 1)}}linear-gradient(90deg, rgba(255,215,0,0.2) 0%, transparent 100%){{else}}{{#if (eq rank 2)}}linear-gradient(90deg, rgba(192,192,192,0.2) 0%, transparent 100%){{else}}{{#if (eq rank 3)}}linear-gradient(90deg, rgba(205,127,50,0.2) 0%, transparent 100%){{else}}rgba(255,255,255,0.05){{/if}}{{/if}}{{/if}}; border-radius: 12px; {{#if (eq rank 1)}}border-left: 3px solid #ffd700;{{/if}}{{#if (eq rank 2)}}border-left: 3px solid #c0c0c0;{{/if}}{{#if (eq rank 3)}}border-left: 3px solid #cd7f32;{{/if}}">
<div style="width: 28px; height: 28px; border-radius: 50%; background: {{#if (eq rank 1)}}#ffd700{{else}}{{#if (eq rank 2)}}#c0c0c0{{else}}{{#if (eq rank 3)}}#cd7f32{{else}}rgba(255,255,255,0.1){{/if}}{{/if}}{{/if}}; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 12px; margin-right: 12px; {{#if (lte rank 3)}}color: #1a1a2e;{{/if}}">{{rank}}</div>
<div style="font-size: 28px; margin-right: 12px;">{{avatar}}</div>
<div style="flex: 1;">
<div style="font-weight: 600; font-size: 14px;">{{name}}</div>
<div style="font-size: 11px; color: #888; margin-top: 2px;">{{team}}</div>
</div>
<div style="text-align: right;">
<div style="font-size: 18px; font-weight: 700; color: #fff;">{{formatNumber score}}</div>
<div style="font-size: 12px; margin-top: 2px; color: {{#if (eq trend 'up')}}#38ef7d{{else}}{{#if (eq trend 'down')}}#f5576c{{else}}#888{{/if}}{{/if}};">
{{#if (eq trend 'up')}}↑{{/if}}{{#if (eq trend 'down')}}↓{{/if}}{{#if (eq trend 'same')}}→{{/if}}
</div>
</div>
</div>
{{/each}}
</div>
`;
// Timeline template with inline styles
const timelineTemplate = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; position: relative; padding-left: 40px;">
<div style="position: absolute; left: 13px; top: 8px; bottom: 8px; width: 3px; background: linear-gradient(180deg, #667eea 0%, #764ba2 100%); border-radius: 2px;"></div>
{{#each data}}
<div style="position: relative; padding: 12px 0;">
<div style="position: absolute; left: -33px; top: 16px; width: 16px; height: 16px; border-radius: 50%; background: {{#if (eq type 'milestone')}}#ffd700{{else}}{{#if (eq type 'success')}}#38ef7d{{else}}{{#if (eq type 'warning')}}#f5576c{{else}}#667eea{{/if}}{{/if}}{{/if}}; border: 3px solid #fff; box-shadow: 0 0 0 3px {{#if (eq type 'milestone')}}rgba(255,215,0,0.3){{else}}{{#if (eq type 'success')}}rgba(56,239,125,0.3){{else}}{{#if (eq type 'warning')}}rgba(245,87,108,0.3){{else}}rgba(102,126,234,0.3){{/if}}{{/if}}{{/if}};"></div>
<div style="background: #f8f9fa; border-radius: 12px; padding: 16px 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); margin-left: 8px;">
<div style="font-size: 11px; color: #888; text-transform: uppercase; letter-spacing: 0.5px;">{{date}}</div>
<div style="font-size: 16px; font-weight: 600; color: #333; margin: 4px 0;">{{event}}</div>
<div style="font-size: 13px; color: #666; line-height: 1.4;">{{description}}</div>
</div>
</div>
{{/each}}
</div>
`;
// Simple editable template for the interactive demo
const simpleTemplate = `<div style="font-family: sans-serif; padding: 16px;">
<h2 style="margin: 0 0 16px 0;">{{title}}</h2>
<ul style="list-style: none; padding: 0; margin: 0;">
{{#each data}}
<li style="padding: 8px; margin: 4px 0; background: #f5f5f5; border-radius: 4px;">
<strong>{{metric}}</strong>: {{formatNumber value}}
</li>
{{/each}}
</ul>
</div>`;
// Simple CSS for the interactive demo
const simpleCSS = `.handlebars-container {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.handlebars-container h2 {
color: #333;
margin-bottom: 16px;
}
.handlebars-container ul {
list-style: none;
padding: 0;
}
.handlebars-container li {
padding: 12px;
margin: 8px 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 8px;
}`;
export default {
title: 'Chart Plugins/plugin-chart-handlebars',
decorators: [withResizableChartDemo],
};
export const InteractiveHandlebars = ({
handlebarsTemplate,
styleTemplate,
width,
height,
}: {
handlebarsTemplate: string;
styleTemplate: string;
width: number;
height: number;
}) => (
<SuperChart
chartType={VIZ_TYPE}
width={width}
height={height}
queriesData={[{ data: kpiData }]}
formData={{
datasource: '1__table',
viz_type: VIZ_TYPE,
handlebars_template: handlebarsTemplate,
style_template: styleTemplate,
}}
/>
);
InteractiveHandlebars.args = {
handlebarsTemplate: simpleTemplate,
styleTemplate: simpleCSS,
};
InteractiveHandlebars.argTypes = {
handlebarsTemplate: {
control: { type: 'text' },
description: 'Handlebars template for rendering data',
},
styleTemplate: {
control: { type: 'text' },
description: 'CSS styles to apply to the chart',
},
};
InteractiveHandlebars.parameters = {
initialSize: {
width: 600,
height: 400,
},
};
export const KPIDashboard = ({
width,
height,
}: {
width: number;
height: number;
}) => (
<SuperChart
chartType={VIZ_TYPE}
width={width}
height={height}
queriesData={[{ data: kpiData }]}
formData={{
datasource: '1__table',
viz_type: VIZ_TYPE,
handlebars_template: kpiTemplate,
}}
/>
);
KPIDashboard.parameters = {
initialSize: {
width: 900,
height: 280,
},
};
export const Leaderboard = ({
width,
height,
}: {
width: number;
height: number;
}) => (
<SuperChart
chartType={VIZ_TYPE}
width={width}
height={height}
queriesData={[{ data: leaderboardData }]}
formData={{
datasource: '1__table',
viz_type: VIZ_TYPE,
handlebars_template: leaderboardTemplate,
}}
/>
);
Leaderboard.parameters = {
initialSize: {
width: 450,
height: 420,
},
};
export const Timeline = ({
width,
height,
}: {
width: number;
height: number;
}) => (
<SuperChart
chartType={VIZ_TYPE}
width={width}
height={height}
queriesData={[{ data: timelineData }]}
formData={{
datasource: '1__table',
viz_type: VIZ_TYPE,
handlebars_template: timelineTemplate,
}}
/>
);
Timeline.parameters = {
initialSize: {
width: 500,
height: 500,
},
};

View File

@@ -1,62 +0,0 @@
/**
* 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 {
QueryFormData,
QueryFormMetric,
QueryMode,
TimeGranularity,
TimeseriesDataRecord,
} from '@superset-ui/core';
export interface HandlebarsStylesProps {
height: number;
width: number;
}
interface HandlebarsCustomizeProps {
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;
granularitySqla?: string;
time_grain_sqla?: TimeGranularity;
};
export type HandlebarsProps = HandlebarsStylesProps &
HandlebarsCustomizeProps & {
data: TimeseriesDataRecord[];
// add typing here for the props you pass in from transformProps.ts!
formData: HandlebarsQueryFormData;
};