feat(plugin-chart-pivot-table): column, date and conditional formatting (#1217)

* feat(plugin-chart-pivot-table): implement conditional and date formatting

* Use custom icons for expand/collapse

* Fix tests

* Revert changes to ControlForm

* Fix tests

* Rename variable
This commit is contained in:
Kamil Gabryjelski 2021-07-16 14:16:41 +02:00 committed by Yongjie Zhao
parent 89474f84e5
commit fe5f9b093e
9 changed files with 264 additions and 77 deletions

View File

@ -28,10 +28,11 @@
"dependencies": {
"@superset-ui/chart-controls": "0.17.67",
"@superset-ui/core": "0.17.64",
"@superset-ui/react-pivottable": "^0.12.8"
"@superset-ui/react-pivottable": "^0.12.9"
},
"peerDependencies": {
"react": "^16.13.1"
"react": "^16.13.1",
"@ant-design/icons": "^4.2.2"
},
"devDependencies": {
"@babel/types": "^7.13.12",

View File

@ -16,8 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback } from 'react';
import { styled, AdhocMetric, getNumberFormatter, DataRecordValue } from '@superset-ui/core';
import React, { useCallback, useMemo } from 'react';
import { PlusSquareOutlined, MinusSquareOutlined } from '@ant-design/icons';
import {
styled,
AdhocMetric,
getNumberFormatter,
DataRecordValue,
NumberFormatter,
} from '@superset-ui/core';
// @ts-ignore
import PivotTable from '@superset-ui/react-pivottable/PivotTable';
// @ts-ignore
@ -40,6 +47,52 @@ const Styles = styled.div<PivotTableStylesProps>`
`;
const METRIC_KEY = 'metric';
const iconStyle = { stroke: 'black', strokeWidth: '16px' };
const aggregatorsFactory = (formatter: NumberFormatter) => ({
Count: aggregatorTemplates.count(formatter),
'Count Unique Values': aggregatorTemplates.countUnique(formatter),
'List Unique Values': aggregatorTemplates.listUnique(', '),
Sum: aggregatorTemplates.sum(formatter),
Average: aggregatorTemplates.average(formatter),
Median: aggregatorTemplates.median(formatter),
'Sample Variance': aggregatorTemplates.var(1, formatter),
'Sample Standard Deviation': aggregatorTemplates.stdev(1, formatter),
Minimum: aggregatorTemplates.min(formatter),
Maximum: aggregatorTemplates.max(formatter),
First: aggregatorTemplates.first(),
Last: aggregatorTemplates.last(formatter),
'Sum as Fraction of Total': aggregatorTemplates.fractionOf(
aggregatorTemplates.sum(),
'total',
formatter,
),
'Sum as Fraction of Rows': aggregatorTemplates.fractionOf(
aggregatorTemplates.sum(),
'row',
formatter,
),
'Sum as Fraction of Columns': aggregatorTemplates.fractionOf(
aggregatorTemplates.sum(),
'col',
formatter,
),
'Count as Fraction of Total': aggregatorTemplates.fractionOf(
aggregatorTemplates.count(),
'total',
formatter,
),
'Count as Fraction of Rows': aggregatorTemplates.fractionOf(
aggregatorTemplates.count(),
'row',
formatter,
),
'Count as Fraction of Columns': aggregatorTemplates.fractionOf(
aggregatorTemplates.count(),
'col',
formatter,
),
});
export default function PivotTableChart(props: PivotTableProps) {
const {
@ -49,11 +102,11 @@ export default function PivotTableChart(props: PivotTableProps) {
groupbyRows,
groupbyColumns,
metrics,
tableRenderer,
colOrder,
rowOrder,
aggregateFunction,
transposePivot,
combineMetric,
rowSubtotalPosition,
colSubtotalPosition,
colTotals,
@ -63,54 +116,51 @@ export default function PivotTableChart(props: PivotTableProps) {
setDataMask,
selectedFilters,
verboseMap,
columnFormats,
metricsLayout,
metricColorFormatters,
dateFormatters,
} = props;
const adaptiveFormatter = getNumberFormatter(valueFormat);
const defaultFormatter = getNumberFormatter(valueFormat);
const columnFormatsArray = Object.entries(columnFormats);
const hasCustomMetricFormatters = columnFormatsArray.length > 0;
const metricFormatters =
hasCustomMetricFormatters &&
Object.fromEntries(
columnFormatsArray.map(([metric, format]) => [metric, getNumberFormatter(format)]),
);
const aggregators = (tpl => ({
Count: tpl.count(adaptiveFormatter),
'Count Unique Values': tpl.countUnique(adaptiveFormatter),
'List Unique Values': tpl.listUnique(', '),
Sum: tpl.sum(adaptiveFormatter),
Average: tpl.average(adaptiveFormatter),
Median: tpl.median(adaptiveFormatter),
'Sample Variance': tpl.var(1, adaptiveFormatter),
'Sample Standard Deviation': tpl.stdev(1, adaptiveFormatter),
Minimum: tpl.min(adaptiveFormatter),
Maximum: tpl.max(adaptiveFormatter),
First: tpl.first(adaptiveFormatter),
Last: tpl.last(adaptiveFormatter),
'Sum as Fraction of Total': tpl.fractionOf(tpl.sum(), 'total', adaptiveFormatter),
'Sum as Fraction of Rows': tpl.fractionOf(tpl.sum(), 'row', adaptiveFormatter),
'Sum as Fraction of Columns': tpl.fractionOf(tpl.sum(), 'col', adaptiveFormatter),
'Count as Fraction of Total': tpl.fractionOf(tpl.count(), 'total', adaptiveFormatter),
'Count as Fraction of Rows': tpl.fractionOf(tpl.count(), 'row', adaptiveFormatter),
'Count as Fraction of Columns': tpl.fractionOf(tpl.count(), 'col', adaptiveFormatter),
}))(aggregatorTemplates);
const metricNames = metrics.map((metric: string | AdhocMetric) =>
typeof metric === 'string' ? metric : (metric.label as string),
const metricNames = useMemo(
() =>
metrics.map((metric: string | AdhocMetric) =>
typeof metric === 'string' ? metric : (metric.label as string),
),
[metrics],
);
const unpivotedData = data.reduce(
(acc: Record<string, any>[], record: Record<string, any>) => [
...acc,
...metricNames.map((name: string) => ({
...record,
[METRIC_KEY]: name,
value: record[name],
})),
],
[],
const unpivotedData = useMemo(
() =>
data.reduce(
(acc: Record<string, any>[], record: Record<string, any>) => [
...acc,
...metricNames.map((name: string) => ({
...record,
[METRIC_KEY]: name,
value: record[name],
})),
],
[],
),
[data, metricNames],
);
let [rows, cols] = transposePivot ? [groupbyColumns, groupbyRows] : [groupbyRows, groupbyColumns];
if (metricsLayout === MetricsLayoutEnum.ROWS) {
rows = [METRIC_KEY, ...rows];
rows = combineMetric ? [...rows, METRIC_KEY] : [METRIC_KEY, ...rows];
} else {
cols = [METRIC_KEY, ...cols];
cols = combineMetric ? [...cols, METRIC_KEY] : [METRIC_KEY, ...cols];
}
const handleChange = useCallback(
@ -144,11 +194,6 @@ export default function PivotTableChart(props: PivotTableProps) {
[setDataMask],
);
const isActiveFilterValue = useCallback(
(key: string, val: DataRecordValue) => !!selectedFilters && selectedFilters[key]?.includes(val),
[selectedFilters],
);
const toggleFilter = useCallback(
(
e: MouseEvent,
@ -162,6 +207,9 @@ export default function PivotTableChart(props: PivotTableProps) {
return;
}
const isActiveFilterValue = (key: string, val: DataRecordValue) =>
!!selectedFilters && selectedFilters[key]?.includes(val);
const filtersCopy = { ...filters };
delete filtersCopy[METRIC_KEY];
@ -201,10 +249,14 @@ export default function PivotTableChart(props: PivotTableProps) {
data={unpivotedData}
rows={rows}
cols={cols}
aggregators={aggregators}
aggregatorsFactory={aggregatorsFactory}
defaultFormatter={defaultFormatter}
customFormatters={
hasCustomMetricFormatters ? { [METRIC_KEY]: metricFormatters } : undefined
}
aggregatorName={aggregateFunction}
vals={['value']}
rendererName={tableRenderer}
rendererName="Table With Subtotal"
colOrder={colOrder}
rowOrder={rowOrder}
sorters={{
@ -218,10 +270,14 @@ export default function PivotTableChart(props: PivotTableProps) {
highlightHeaderCellsOnHover: emitFilter,
highlightedHeaderCells: selectedFilters,
omittedHighlightHeaderGroups: [METRIC_KEY],
cellColorFormatters: { [METRIC_KEY]: metricColorFormatters },
dateFormatters,
}}
subtotalOptions={{
colSubtotalDisplay: { displayOnTop: colSubtotalPosition },
rowSubtotalDisplay: { displayOnTop: rowSubtotalPosition },
arrowCollapsed: <PlusSquareOutlined style={iconStyle} />,
arrowExpanded: <MinusSquareOutlined style={iconStyle} />,
}}
namesMapping={verboseMap}
/>

View File

@ -16,9 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FeatureFlag, isFeatureEnabled, t, validateNonEmpty } from '@superset-ui/core';
import {
FeatureFlag,
isFeatureEnabled,
QueryFormMetric,
smartDateFormatter,
t,
validateNonEmpty,
} from '@superset-ui/core';
import {
ControlPanelConfig,
D3_TIME_FORMAT_OPTIONS,
formatSelectOptions,
sections,
sharedControls,
@ -140,6 +148,21 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'combineMetric',
config: {
type: 'CheckboxControl',
label: t('Combine metrics'),
default: false,
description: t(
'Display metrics side by side within each column, as ' +
'opposed to each column being displayed side by side for each metric.',
),
renderTrigger: true,
},
},
],
],
},
{
@ -154,23 +177,16 @@ const config: ControlPanelConfig = {
],
[
{
name: 'tableRenderer',
name: 'date_format',
config: {
type: 'SelectControl',
label: t('Pivot table type'),
default: 'Table With Subtotal',
choices: [
// [value, label]
['Table With Subtotal', t('Table')],
['Table With Subtotal Heatmap', t('Table Heatmap')],
['Table With Subtotal Col Heatmap', t('Table Col Heatmap')],
['Table With Subtotal Row Heatmap', t('Table Row Heatmap')],
['Table With Subtotal Barchart', t('Table Barchart')],
['Table With Subtotal Col Barchart', t('Table Col Barchart')],
['Table With Subtotal Row Barchart', t('Table Row Barchart')],
],
freeForm: true,
label: t('Date format'),
default: smartDateFormatter.id,
renderTrigger: true,
description: t('The type of pivot table visualization'),
clearable: false,
choices: D3_TIME_FORMAT_OPTIONS,
description: t('D3 time format for datetime columns'),
},
},
],
@ -280,6 +296,31 @@ const config: ControlPanelConfig = {
},
]
: [],
[
{
name: 'conditional_formatting',
config: {
type: 'ConditionalFormattingControl',
renderTrigger: true,
label: t('Customize metrics'),
description: t('Apply conditional color formatting to metrics'),
mapStateToProps(explore) {
const values = (explore?.controls?.metrics?.value as QueryFormMetric[]) ?? [];
const verboseMap = explore?.datasource?.verbose_map ?? {};
const metricColumn = values.map(value => {
if (typeof value === 'string') {
return { value, label: verboseMap[value] ?? value };
}
return { value: value.label, label: value.label };
});
return {
columnOptions: metricColumn,
verboseMap,
};
},
},
},
],
],
},
],

View File

@ -16,14 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
import {
t,
ChartMetadata,
ChartPlugin,
Behavior,
ChartProps,
QueryFormData,
} from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import thumbnail from '../images/thumbnail.png';
import { PivotTableQueryFormData } from '../types';
export default class PivotTableChartPlugin extends ChartPlugin<PivotTableQueryFormData> {
export default class PivotTableChartPlugin extends ChartPlugin<
PivotTableQueryFormData,
ChartProps<QueryFormData>
> {
/**
* The constructor is used to pass relevant metadata and callbacks that get
* registered in respective registries that are used throughout the library

View File

@ -16,9 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps, DataRecord } from '@superset-ui/core';
import {
ChartProps,
DataRecord,
extractTimegrain,
GenericDataType,
getTimeFormatter,
getTimeFormatterForGranularity,
QueryFormData,
smartDateFormatter,
TimeFormats,
} from '@superset-ui/core';
import { getColorFormatters } from '@superset-ui/chart-controls';
import { DateFormatter } from '../types';
export default function transformProps(chartProps: ChartProps) {
const { DATABASE_DATETIME } = TimeFormats;
const TIME_COLUMN = '__timestamp';
function isNumeric(key: string, data: DataRecord[] = []) {
return data.every(
record => record[key] === null || record[key] === undefined || typeof record[key] === 'number',
);
}
export default function transformProps(chartProps: ChartProps<QueryFormData>) {
/**
* This function is called after a successful response has been
* received from the chart data endpoint, and is used to transform
@ -53,11 +74,12 @@ export default function transformProps(chartProps: ChartProps) {
height,
queriesData,
formData,
rawFormData,
hooks: { setDataMask = () => {} },
filterState,
datasource: { verboseMap = {} },
datasource: { verboseMap = {}, columnFormats = {} },
} = chartProps;
const data = queriesData[0].data as DataRecord[];
const { data, colnames, coltypes } = queriesData[0];
const {
groupbyRows,
groupbyColumns,
@ -67,15 +89,43 @@ export default function transformProps(chartProps: ChartProps) {
rowOrder,
aggregateFunction,
transposePivot,
combineMetric,
rowSubtotalPosition,
colSubtotalPosition,
colTotals,
rowTotals,
valueFormat,
dateFormat,
emitFilter,
metricsLayout,
conditionalFormatting,
} = formData;
const { selectedFilters } = filterState;
const granularity = extractTimegrain(rawFormData);
const dateFormatters = colnames
.filter((colname: string, index: number) => coltypes[index] === GenericDataType.TEMPORAL)
.reduce((acc: Record<string, DateFormatter | undefined>, temporalColname: string) => {
let formatter: DateFormatter | undefined;
if (dateFormat === smartDateFormatter.id) {
if (temporalColname === TIME_COLUMN) {
// time column use formats based on granularity
formatter = getTimeFormatterForGranularity(granularity);
} else if (isNumeric(temporalColname, data)) {
formatter = getTimeFormatter(DATABASE_DATETIME);
} else {
// if no column-specific format, print cell as is
formatter = String;
}
} else if (dateFormat) {
formatter = getTimeFormatter(dateFormat);
}
if (formatter) {
acc[temporalColname] = formatter;
}
return acc;
}, {});
const metricColorFormatters = getColorFormatters(conditionalFormatting, data);
return {
width,
@ -89,6 +139,7 @@ export default function transformProps(chartProps: ChartProps) {
rowOrder,
aggregateFunction,
transposePivot,
combineMetric,
rowSubtotalPosition,
colSubtotalPosition,
colTotals,
@ -98,6 +149,9 @@ export default function transformProps(chartProps: ChartProps) {
setDataMask,
selectedFilters,
verboseMap,
columnFormats,
metricsLayout,
metricColorFormatters,
dateFormatters,
};
}

View File

@ -23,7 +23,10 @@ import {
SetDataMaskHook,
DataRecordValue,
JsonObject,
TimeFormatter,
NumberFormatter,
} from '@superset-ui/core';
import { ColorFormatters } from '@superset-ui/chart-controls';
export interface PivotTableStylesProps {
height: number;
@ -33,6 +36,7 @@ export interface PivotTableStylesProps {
export type FilterType = Record<string, DataRecordValue>;
export type SelectedFiltersType = Record<string, DataRecordValue[]>;
export type DateFormatter = TimeFormatter | NumberFormatter | ((value: DataRecordValue) => string);
export enum MetricsLayoutEnum {
ROWS = 'ROWS',
COLUMNS = 'COLUMNS',
@ -47,6 +51,7 @@ interface PivotTableCustomizeProps {
rowOrder: string;
aggregateFunction: string;
transposePivot: boolean;
combineMetric: boolean;
rowSubtotalPosition: boolean;
colSubtotalPosition: boolean;
colTotals: boolean;
@ -55,8 +60,11 @@ interface PivotTableCustomizeProps {
setDataMask: SetDataMaskHook;
emitFilter?: boolean;
selectedFilters?: SelectedFiltersType;
verboseMap?: JsonObject;
verboseMap: JsonObject;
columnFormats: JsonObject;
metricsLayout?: MetricsLayoutEnum;
metricColorFormatters: ColorFormatters;
dateFormatters: Record<string, DateFormatter | undefined>;
}
export type PivotTableQueryFormData = QueryFormData &

View File

@ -19,6 +19,12 @@ describe('PivotTableChart buildQuery', () => {
viz_type: 'my_chart',
width: 800,
height: 600,
combineMetric: false,
verboseMap: {},
columnFormats: {},
metricColorFormatters: [],
dateFormatters: {},
setDataMask: () => {},
};
it('should build groupby with series in form data', () => {

View File

@ -1,4 +1,4 @@
import { ChartProps } from '@superset-ui/core';
import { ChartProps, QueryFormData } from '@superset-ui/core';
import transformProps from '../../src/plugin/transformProps';
import { MetricsLayoutEnum } from '../../src/types';
@ -13,6 +13,7 @@ describe('PivotTableChart transformProps', () => {
rowOrder: 'key_a_to_z',
aggregateFunction: 'Sum',
transposePivot: true,
combineMetric: true,
rowSubtotalPosition: true,
colSubtotalPosition: true,
colTotals: true,
@ -20,19 +21,25 @@ describe('PivotTableChart transformProps', () => {
valueFormat: 'SMART_NUMBER',
emitFilter: false,
metricsLayout: MetricsLayoutEnum.COLUMNS,
viz_type: '',
datasource: '',
conditionalFormatting: [],
dateFormat: '',
};
const chartProps = new ChartProps({
const chartProps = new ChartProps<QueryFormData>({
formData,
width: 800,
height: 600,
queriesData: [
{
data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }],
colnames: ['name', 'sum__num', '__timestamp'],
coltypes: [1, 0, 2],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: { verboseMap: {} },
datasource: { verboseMap: {}, columnFormats: {} },
});
it('should transform chart props for viz', () => {
@ -47,6 +54,7 @@ describe('PivotTableChart transformProps', () => {
rowOrder: 'key_a_to_z',
aggregateFunction: 'Sum',
transposePivot: true,
combineMetric: true,
rowSubtotalPosition: true,
colSubtotalPosition: true,
colTotals: true,
@ -58,6 +66,9 @@ describe('PivotTableChart transformProps', () => {
selectedFilters: {},
verboseMap: {},
metricsLayout: MetricsLayoutEnum.COLUMNS,
metricColorFormatters: [],
dateFormatters: {},
columnFormats: {},
});
});
});

View File

@ -4396,10 +4396,10 @@
d3-cloud "^1.2.1"
prop-types "^15.6.2"
"@superset-ui/react-pivottable@^0.12.8":
version "0.12.8"
resolved "https://registry.yarnpkg.com/@superset-ui/react-pivottable/-/react-pivottable-0.12.8.tgz#3b7d6cd32719d6510b88b2334afacbb1735afee5"
integrity sha512-7DRxX/w1uSQE1pibSe64t1o+fmiP7ZWT2FJkjK510bSJm8NUIPCXtmpK+NKtNZuCteE9sqE7bQxd54SSq2xWKw==
"@superset-ui/react-pivottable@^0.12.9":
version "0.12.9"
resolved "https://registry.yarnpkg.com/@superset-ui/react-pivottable/-/react-pivottable-0.12.9.tgz#f46ceef940c2f99c197db4acd7487efc48be05bf"
integrity sha512-wztcGEGg4Fc/zxHTDSP2ANyE9aZYiDPHf01FSx6rBapxtXP04NvyLPJl3RKNN/D5l98o5bj5xO/5KSailM9LjQ==
dependencies:
immutability-helper "^3.1.1"
prop-types "^15.7.2"