fix(plugin-chart-period-over-period-kpi): Blank chart when switching from BigNumberTotal (#27203)

This commit is contained in:
Kamil Gabryjelski
2024-02-22 17:37:23 +01:00
committed by GitHub
parent f1cd8cc263
commit 54037972f2
17 changed files with 121 additions and 290 deletions

View File

@@ -0,0 +1,188 @@
/**
* 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 React, { createRef, useMemo } from 'react';
import { css, styled, t, useTheme } from '@superset-ui/core';
import { Tooltip } from '@superset-ui/chart-controls';
import {
PopKPIComparisonSymbolStyleProps,
PopKPIComparisonValueStyleProps,
PopKPIProps,
} from './types';
const ComparisonValue = styled.div<PopKPIComparisonValueStyleProps>`
${({ theme, subheaderFontSize }) => `
font-weight: ${theme.typography.weights.light};
width: 33%;
display: table-cell;
font-size: ${subheaderFontSize || 20}px;
text-align: center;
`}
`;
const SymbolWrapper = styled.div<PopKPIComparisonSymbolStyleProps>`
${({ theme, backgroundColor, textColor }) => `
background-color: ${backgroundColor};
color: ${textColor};
padding: ${theme.gridUnit}px ${theme.gridUnit * 2}px;
border-radius: ${theme.gridUnit * 2}px;
display: inline-block;
margin-right: ${theme.gridUnit}px;
`}
`;
export default function PopKPI(props: PopKPIProps) {
const {
height,
width,
bigNumber,
prevNumber,
valueDifference,
percentDifferenceFormattedString,
headerFontSize,
subheaderFontSize,
comparisonColorEnabled,
percentDifferenceNumber,
comparatorText,
} = props;
const rootElem = createRef<HTMLDivElement>();
const theme = useTheme();
const wrapperDivStyles = css`
font-family: ${theme.typography.families.sansSerif};
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: ${theme.gridUnit * 4}px;
border-radius: ${theme.gridUnit * 2}px;
height: ${height}px;
width: ${width}px;
`;
const bigValueContainerStyles = css`
font-size: ${headerFontSize || 60}px;
font-weight: ${theme.typography.weights.normal};
text-align: center;
`;
const getArrowIndicatorColor = () => {
if (!comparisonColorEnabled) return theme.colors.grayscale.base;
return percentDifferenceNumber > 0
? theme.colors.success.base
: theme.colors.error.base;
};
const arrowIndicatorStyle = css`
color: ${getArrowIndicatorColor()};
margin-left: ${theme.gridUnit}px;
`;
const defaultBackgroundColor = theme.colors.grayscale.light4;
const defaultTextColor = theme.colors.grayscale.base;
const { backgroundColor, textColor } = useMemo(() => {
let bgColor = defaultBackgroundColor;
let txtColor = defaultTextColor;
if (percentDifferenceNumber > 0) {
if (comparisonColorEnabled) {
bgColor = theme.colors.success.light2;
txtColor = theme.colors.success.base;
}
} else if (percentDifferenceNumber < 0) {
if (comparisonColorEnabled) {
bgColor = theme.colors.error.light2;
txtColor = theme.colors.error.base;
}
}
return {
backgroundColor: bgColor,
textColor: txtColor,
};
}, [theme, comparisonColorEnabled, percentDifferenceNumber]);
const SYMBOLS_WITH_VALUES = useMemo(
() => [
{
symbol: '#',
value: prevNumber,
tooltipText: t('Data for %s', comparatorText),
},
{
symbol: '△',
value: valueDifference,
tooltipText: t('Value difference between the time periods'),
},
{
symbol: '%',
value: percentDifferenceFormattedString,
tooltipText: t('Percentage difference between the time periods'),
},
],
[prevNumber, valueDifference, percentDifferenceFormattedString],
);
return (
<div ref={rootElem} css={wrapperDivStyles}>
<div css={bigValueContainerStyles}>
{bigNumber}
{percentDifferenceNumber !== 0 && (
<span css={arrowIndicatorStyle}>
{percentDifferenceNumber > 0 ? '↑' : '↓'}
</span>
)}
</div>
<div
css={css`
width: 100%;
display: table;
`}
>
<div
css={css`
display: table-row;
`}
>
{SYMBOLS_WITH_VALUES.map((symbol_with_value, index) => (
<ComparisonValue
key={`comparison-symbol-${symbol_with_value.symbol}`}
subheaderFontSize={subheaderFontSize}
>
<Tooltip
id="tooltip"
placement="top"
title={symbol_with_value.tooltipText}
>
<SymbolWrapper
backgroundColor={
index > 0 ? backgroundColor : defaultBackgroundColor
}
textColor={index > 0 ? textColor : defaultTextColor}
>
{symbol_with_value.symbol}
</SymbolWrapper>
{symbol_with_value.value}
</Tooltip>
</ComparisonValue>
))}
</div>
</div>
</div>
);
}

View File

@@ -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 {
buildQueryContext,
getComparisonInfo,
ComparisonTimeRangeType,
QueryFormData,
} from '@superset-ui/core';
export default function buildQuery(formData: QueryFormData) {
const {
cols: groupby,
time_comparison: timeComparison,
extra_form_data: extraFormData,
} = formData;
const queryContextA = buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
groupby,
},
]);
const comparisonFormData = getComparisonInfo(
formData,
timeComparison,
extraFormData,
);
const queryContextB = buildQueryContext(
comparisonFormData,
baseQueryObject => [
{
...baseQueryObject,
groupby,
extras: {
...baseQueryObject.extras,
instant_time_comparison_range:
timeComparison !== ComparisonTimeRangeType.Custom
? timeComparison
: undefined,
},
},
],
);
return {
...queryContextA,
queries: [...queryContextA.queries, ...queryContextB.queries],
};
}

View File

@@ -0,0 +1,142 @@
/**
* 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 {
ComparisonTimeRangeType,
t,
validateTimeComparisonRangeValues,
} from '@superset-ui/core';
import {
ControlPanelConfig,
ControlPanelState,
ControlState,
getStandardizedControls,
sharedControls,
} from '@superset-ui/chart-controls';
import { headerFontSize, subheaderFontSize } from '../sharedControls';
const config: ControlPanelConfig = {
controlPanelSections: [
{
label: t('Query'),
expanded: true,
controlSetRows: [
['metric'],
['adhoc_filters'],
[
{
name: 'time_comparison',
config: {
type: 'SelectControl',
label: t('Range for Comparison'),
default: 'r',
choices: [
['r', 'Inherit range from time filters'],
['y', 'Year'],
['m', 'Month'],
['w', 'Week'],
['c', 'Custom'],
],
rerender: ['adhoc_custom'],
description: t(
'Set the time range that will be used for the comparison metrics. ' +
'For example, "Year" will compare to the same dates one year earlier. ' +
'Use "Inherit range from time filters" to shift the comparison time range' +
'by the same length as your time range and use "Custom" to set a custom comparison range.',
),
},
},
],
[
{
name: `adhoc_custom`,
config: {
...sharedControls.adhoc_filters,
label: t('Filters for Comparison'),
description:
'This only applies when selecting the Range for Comparison Type: Custom',
visibility: ({ controls }) =>
controls?.time_comparison?.value ===
ComparisonTimeRangeType.Custom,
mapStateToProps: (
state: ControlPanelState,
controlState: ControlState,
) => ({
...(sharedControls.adhoc_filters.mapStateToProps?.(
state,
controlState,
) || {}),
externalValidationErrors: validateTimeComparisonRangeValues(
state.controls?.time_comparison?.value,
controlState.value,
),
}),
},
},
],
[
{
name: 'row_limit',
config: sharedControls.row_limit,
},
],
],
},
{
label: t('Chart Options'),
expanded: true,
controlSetRows: [
['y_axis_format'],
['currency_format'],
[headerFontSize],
[
{
...subheaderFontSize,
config: {
...subheaderFontSize.config,
label: t('Comparison font size'),
},
},
],
[
{
name: 'comparison_color_enabled',
config: {
type: 'CheckboxControl',
label: t('Add color for positive/negative change'),
renderTrigger: true,
default: false,
description: t('Add color for positive/negative change'),
},
},
],
],
},
],
controlOverrides: {
y_axis_format: {
label: t('Number format'),
},
},
formDataOverrides: formData => ({
...formData,
metric: getStandardizedControls().shiftMetric(),
}),
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -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 { t, ChartMetadata, ChartPlugin } from '@superset-ui/core';
import buildQuery from './buildQuery';
import controlPanel from './controlPanel';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
export default class PopKPIPlugin extends ChartPlugin {
constructor() {
const metadata = new ChartMetadata({
category: t('KPI'),
description:
'Showcases a metric along with a comparison of value, change, and percent change for a selected time period.',
name: t('Big Number with Time Period Comparison'),
tags: [
t('Comparison'),
t('Business'),
t('Percentages'),
t('Report'),
t('Description'),
t('Advanced-Analytics'),
],
thumbnail,
});
super({
buildQuery,
controlPanel,
loadChart: () => import('./PopKPI'),
metadata,
transformProps,
});
}
}

View File

@@ -0,0 +1,161 @@
/**
* 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 moment from 'moment';
import {
ChartProps,
getMetricLabel,
getValueFormatter,
NumberFormats,
getNumberFormatter,
formatTimeRange,
} from '@superset-ui/core';
import { getComparisonFontSize, getHeaderFontSize } from './utils';
export const parseMetricValue = (metricValue: number | string | null) => {
if (typeof metricValue === 'string') {
const dateObject = moment.utc(metricValue, moment.ISO_8601, true);
if (dateObject.isValid()) {
return dateObject.valueOf();
}
return 0;
}
return metricValue ?? 0;
};
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 CustomViz.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,
datasource: { currencyFormats = {}, columnFormats = {} },
} = chartProps;
const {
boldText,
headerFontSize,
headerText,
metric,
yAxisFormat,
currencyFormat,
subheaderFontSize,
comparisonColorEnabled,
} = formData;
const { data: dataA = [] } = queriesData[0];
const {
data: dataB = [],
from_dttm: comparisonFromDatetime,
to_dttm: comparisonToDatetime,
} = queriesData[1];
const data = dataA;
const metricName = getMetricLabel(metric);
let bigNumber: number | string =
data.length === 0 ? 0 : parseMetricValue(data[0][metricName]);
let prevNumber: number | string =
data.length === 0 ? 0 : parseMetricValue(dataB[0][metricName]);
const numberFormatter = getValueFormatter(
metric,
currencyFormats,
columnFormats,
yAxisFormat,
currencyFormat,
);
const compTitles = {
r: 'Range' as string,
y: 'Year' as string,
m: 'Month' as string,
w: 'Week' as string,
};
const formatPercentChange = getNumberFormatter(
NumberFormats.PERCENT_SIGNED_1_POINT,
);
let valueDifference: number | string = bigNumber - prevNumber;
let percentDifferenceNum;
if (!bigNumber && !prevNumber) {
percentDifferenceNum = 0;
} else if (!bigNumber || !prevNumber) {
percentDifferenceNum = bigNumber ? 1 : -1;
} else {
percentDifferenceNum = (bigNumber - prevNumber) / Math.abs(prevNumber);
}
const compType = compTitles[formData.timeComparison];
bigNumber = numberFormatter(bigNumber);
prevNumber = numberFormatter(prevNumber);
valueDifference = numberFormatter(valueDifference);
const percentDifference: string = formatPercentChange(percentDifferenceNum);
const comparatorText = formatTimeRange('%Y-%m-%d', [
comparisonFromDatetime,
comparisonToDatetime,
]);
return {
width,
height,
data,
metric,
metricName,
bigNumber,
prevNumber,
valueDifference,
percentDifferenceFormattedString: percentDifference,
boldText,
headerFontSize: getHeaderFontSize(headerFontSize),
subheaderFontSize: getComparisonFontSize(subheaderFontSize),
headerText,
compType,
comparisonColorEnabled,
percentDifferenceNumber: percentDifferenceNum,
comparatorText,
};
}

View File

@@ -0,0 +1,64 @@
/**
* 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,
supersetTheme,
TimeseriesDataRecord,
Metric,
} from '@superset-ui/core';
export interface PopKPIStylesProps {
height: number;
width: number;
headerFontSize: keyof typeof supersetTheme.typography.sizes;
subheaderFontSize: keyof typeof supersetTheme.typography.sizes;
boldText: boolean;
comparisonColorEnabled: boolean;
}
interface PopKPICustomizeProps {
headerText: string;
}
export interface PopKPIComparisonValueStyleProps {
subheaderFontSize?: keyof typeof supersetTheme.typography.sizes;
}
export interface PopKPIComparisonSymbolStyleProps {
backgroundColor: string;
textColor: string;
}
export type PopKPIQueryFormData = QueryFormData &
PopKPIStylesProps &
PopKPICustomizeProps;
export type PopKPIProps = PopKPIStylesProps &
PopKPICustomizeProps & {
data: TimeseriesDataRecord[];
metrics: Metric[];
metricName: string;
bigNumber: string;
prevNumber: string;
valueDifference: string;
percentDifferenceFormattedString: string;
compType: string;
percentDifferenceNumber: number;
comparatorText: string;
};

View File

@@ -0,0 +1,39 @@
/**
* 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 { getComparisonFontSize, getHeaderFontSize } from './utils';
test('getHeaderFontSize', () => {
expect(getHeaderFontSize(0.2)).toEqual(16);
expect(getHeaderFontSize(0.3)).toEqual(20);
expect(getHeaderFontSize(0.4)).toEqual(30);
expect(getHeaderFontSize(0.5)).toEqual(48);
expect(getHeaderFontSize(0.6)).toEqual(60);
expect(getHeaderFontSize(0.15)).toEqual(60);
expect(getHeaderFontSize(2)).toEqual(60);
});
test('getComparisonFontSize', () => {
expect(getComparisonFontSize(0.125)).toEqual(16);
expect(getComparisonFontSize(0.15)).toEqual(20);
expect(getComparisonFontSize(0.2)).toEqual(26);
expect(getComparisonFontSize(0.3)).toEqual(32);
expect(getComparisonFontSize(0.4)).toEqual(40);
expect(getComparisonFontSize(0.05)).toEqual(40);
expect(getComparisonFontSize(0.9)).toEqual(40);
});

View File

@@ -0,0 +1,59 @@
/**
* 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 { headerFontSize, subheaderFontSize } from '../sharedControls';
const headerFontSizes = [16, 20, 30, 48, 60];
const comparisonFontSizes = [16, 20, 26, 32, 40];
const headerProportionValues =
headerFontSize.config.options.map(
(option: { label: string; value: number }) => option.value,
) ?? [];
const subheaderProportionValues =
subheaderFontSize.config.options.map(
(option: { label: string; value: number }) => option.value,
) ?? [];
const getFontSizeMapping = (
proportionValues: number[],
actualSizes: number[],
) =>
proportionValues.reduce((acc, value, index) => {
acc[value] = actualSizes[index] ?? actualSizes[actualSizes.length - 1];
return acc;
}, {});
const headerFontSizesMapping = getFontSizeMapping(
headerProportionValues,
headerFontSizes,
);
const comparisonFontSizesMapping = getFontSizeMapping(
subheaderProportionValues,
comparisonFontSizes,
);
export const getHeaderFontSize = (proportionValue: number) =>
headerFontSizesMapping[proportionValue] ??
headerFontSizes[headerFontSizes.length - 1];
export const getComparisonFontSize = (proportionValue: number) =>
comparisonFontSizesMapping[proportionValue] ??
comparisonFontSizes[comparisonFontSizes.length - 1];

View File

@@ -19,3 +19,4 @@
export { default as BigNumberChartPlugin } from './BigNumberWithTrendline';
export { default as BigNumberTotalChartPlugin } from './BigNumberTotal';
export { default as BigNumberPeriodOverPeriodChartPlugin } from './BigNumberPeriodOverPeriod';

View File

@@ -32,7 +32,11 @@ export { default as EchartsRadarChartPlugin } from './Radar';
export { default as EchartsFunnelChartPlugin } from './Funnel';
export { default as EchartsTreeChartPlugin } from './Tree';
export { default as EchartsTreemapChartPlugin } from './Treemap';
export { BigNumberChartPlugin, BigNumberTotalChartPlugin } from './BigNumber';
export {
BigNumberChartPlugin,
BigNumberTotalChartPlugin,
BigNumberPeriodOverPeriodChartPlugin,
} from './BigNumber';
export { default as EchartsSunburstChartPlugin } from './Sunburst';
export { default as EchartsBubbleChartPlugin } from './Bubble';
export { default as EchartsWaterfallChartPlugin } from './Waterfall';