fix(chart): Restore subheader used in bignumber with trendline (#33196)

This commit is contained in:
Levis Mbote
2025-04-25 15:39:07 +03:00
committed by GitHub
parent fbd8ae2888
commit 6a586fe4fd
7 changed files with 332 additions and 15 deletions

View File

@@ -103,6 +103,38 @@ describe('BigNumberTotal transformProps', () => {
expect(result.subtitle).toBe('test');
});
const baseChartProps = {
width: 400,
height: 300,
queriesData: [{ data: [], coltypes: [] }],
rawFormData: { dummy: 'raw' },
hooks: { onContextMenu: jest.fn() },
datasource: {
currencyFormats: { value: '$0,0.00' },
columnFormats: { value: '$0,0.00' },
metrics: [{ metric_name: 'value', d3format: '.2f' }],
},
};
it('uses subtitle font size when subtitle is provided', () => {
const result = transformProps({
...baseChartProps,
formData: {
subtitle: 'Subtitle wins',
subheader: 'Fallback subheader',
subtitleFontSize: 0.4,
subheaderFontSize: 0.99,
metric: 'value',
headerFontSize: 0.3,
yAxisFormat: 'SMART_NUMBER',
timeFormat: 'smart_date',
},
} as unknown as BigNumberTotalChartProps);
expect(result.subtitle).toBe('Subtitle wins');
expect(result.subtitleFontSize).toBe(0.4);
});
it('should compute bigNumber using parseMetricValue when data exists', () => {
const chartProps = {
width: 500,

View File

@@ -61,8 +61,10 @@ export default function transformProps(
const { data = [], coltypes = [] } = queriesData[0];
const granularity = extractTimegrain(rawFormData as QueryFormData);
const metricName = getMetricLabel(metric);
const formattedSubtitle = subtitle || subheader || '';
const formattedSubtitleFontSize = subheaderFontSize || subtitleFontSize;
const formattedSubtitle = subtitle?.trim() ? subtitle : subheader || '';
const formattedSubtitleFontSize = subtitle?.trim()
? (subtitleFontSize ?? 1)
: (subheaderFontSize ?? 1);
const bigNumber =
data.length === 0 ? null : parseMetricValue(data[0][metricName]);
@@ -108,6 +110,7 @@ export default function transformProps(
bigNumber,
headerFormatter,
headerFontSize,
subheaderFontSize,
subtitleFontSize: formattedSubtitleFontSize,
subtitle: formattedSubtitle,
onContextMenu,

View File

@@ -188,19 +188,21 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
);
}
renderSubtitle(maxHeight: number) {
const { subtitle, width } = this.props;
rendermetricComparisonSummary(maxHeight: number) {
const { subheader, width } = this.props;
let fontSize = 0;
if (subtitle) {
const text = subheader;
if (text) {
const container = this.createTemporaryContainer();
document.body.append(container);
try {
fontSize = computeMaxFontSize({
text: subtitle,
text,
maxWidth: width * 0.9,
maxHeight,
className: 'subtitle-line',
className: 'subheader-line',
container,
});
} finally {
@@ -209,19 +211,65 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
return (
<div
className="subtitle-line"
className="subheader-line"
style={{
fontSize,
height: maxHeight,
}}
>
{subtitle}
{text}
</div>
);
}
return null;
}
renderSubtitle(maxHeight: number) {
const { subtitle, width, bigNumber, bigNumberFallback } = this.props;
let fontSize = 0;
const NO_DATA_OR_HASNT_LANDED = t(
'No data after filtering or data is NULL for the latest time record',
);
const NO_DATA = t(
'Try applying different filters or ensuring your datasource has data',
);
let text = subtitle;
if (bigNumber === null) {
text =
subtitle || (bigNumberFallback ? NO_DATA : NO_DATA_OR_HASNT_LANDED);
}
if (text) {
const container = this.createTemporaryContainer();
document.body.append(container);
fontSize = computeMaxFontSize({
text,
maxWidth: width * 0.9,
maxHeight,
className: 'subtitle-line',
container,
});
container.remove();
return (
<>
<div
className="subtitle-line subheader-line"
style={{
fontSize: `${fontSize}px`,
height: maxHeight,
}}
>
{text}
</div>
</>
);
}
return null;
}
renderTrendline(maxHeight: number) {
const { width, trendLineData, echartOptions, refs } = this.props;
@@ -275,6 +323,7 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
kickerFontSize,
headerFontSize,
subtitleFontSize,
subheaderFontSize,
} = this.props;
const className = this.getClassName();
@@ -294,6 +343,11 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
{this.renderHeader(
Math.ceil(headerFontSize * (1 - PROPORTION.TRENDLINE) * height),
)}
{this.rendermetricComparisonSummary(
Math.ceil(
subheaderFontSize * (1 - PROPORTION.TRENDLINE) * height,
),
)}
{this.renderSubtitle(
Math.ceil(subtitleFontSize * (1 - PROPORTION.TRENDLINE) * height),
)}
@@ -308,6 +362,9 @@ class BigNumberVis extends PureComponent<BigNumberVizProps> {
{this.renderFallbackWarning()}
{this.renderKicker((kickerFontSize || 0) * height)}
{this.renderHeader(Math.ceil(headerFontSize * height))}
{this.rendermetricComparisonSummary(
Math.ceil(subheaderFontSize * height),
)}
{this.renderSubtitle(Math.ceil(subtitleFontSize * height))}
</div>
);
@@ -359,12 +416,12 @@ export default styled(BigNumberVis)`
.subheader-line {
line-height: 1em;
padding-bottom: 0.3em;
padding-bottom: 0;
}
.subtitle-line {
line-height: 1em;
padding-top: 0.3em;
padding-bottom: 0;
}
&.is-fallback-value {

View File

@@ -0,0 +1,196 @@
/**
* 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 { GenericDataType } from '@superset-ui/core';
import transformProps from './transformProps';
import { BigNumberWithTrendlineChartProps, BigNumberDatum } from '../types';
jest.mock('@superset-ui/core', () => ({
GenericDataType: { Temporal: 2, String: 1 },
extractTimegrain: jest.fn(() => 'P1D'),
getMetricLabel: jest.fn(metric => metric),
getXAxisLabel: jest.fn(() => '__timestamp'),
getValueFormatter: jest.fn(() => ({
format: (v: number) => `$${v}`,
})),
getNumberFormatter: jest.fn(() => (v: number) => `${(v * 100).toFixed(1)}%`),
t: jest.fn(v => v),
tooltipHtml: jest.fn(() => '<div>tooltip</div>'),
NumberFormats: {
PERCENT_SIGNED_1_POINT: '.1%',
},
}));
jest.mock('../utils', () => ({
getDateFormatter: jest.fn(() => (v: any) => `${v}pm`),
parseMetricValue: jest.fn(val => Number(val)),
}));
jest.mock('../../utils/tooltip', () => ({
getDefaultTooltip: jest.fn(() => ({})),
}));
describe('BigNumberWithTrendline transformProps', () => {
const onContextMenu = jest.fn();
const baseFormData = {
headerFontSize: 20,
metric: 'value',
subtitle: 'subtitle message',
subtitleFontSize: 14,
forceTimestampFormatting: false,
timeFormat: 'YYYY-MM-DD',
yAxisFormat: 'SMART_NUMBER',
compareLag: 1,
compareSuffix: 'WoW',
colorPicker: { r: 0, g: 0, b: 0 },
currencyFormat: { symbol: '$', symbolPosition: 'prefix' },
};
const baseDatasource = {
currencyFormats: { value: '$0,0.00' },
columnFormats: { value: '$0,0.00' },
metrics: [{ metric_name: 'value', d3format: '.2f' }],
};
const baseHooks = { onContextMenu };
const baseRawFormData = { dummy: 'raw' };
it('should return null bigNumber when no data is provided', () => {
const chartProps = {
width: 400,
height: 300,
queriesData: [{ data: [] as unknown as BigNumberDatum[], coltypes: [] }],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.bigNumber).toBeNull();
expect(result.subtitle).toBe('subtitle message');
});
it('should calculate subheader as percent change with suffix', () => {
const chartProps = {
width: 500,
height: 400,
queriesData: [
{
data: [
{ __timestamp: 2, value: 110 },
{ __timestamp: 1, value: 100 },
] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: ['TEMPORAL', 'NUMERIC'],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.subheader).toBe('10.0% WoW');
});
it('should compute bigNumber from parseMetricValue', () => {
const chartProps = {
width: 600,
height: 450,
queriesData: [
{
data: [
{ __timestamp: 2, value: '456' },
] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: [GenericDataType.Temporal, GenericDataType.String],
},
],
formData: baseFormData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.bigNumber).toEqual(456);
});
it('should use formatTime as headerFormatter for Temporal/String or forced', () => {
const formData = { ...baseFormData, forceTimestampFormatting: true };
const chartProps = {
width: 600,
height: 450,
queriesData: [
{
data: [
{ __timestamp: 2, value: '123' },
] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: [0, GenericDataType.String],
},
],
formData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.headerFormatter(5)).toBe('5pm');
});
it('should use numberFormatter when not Temporal/String and not forced', () => {
const formData = { ...baseFormData, forceTimestampFormatting: false };
const chartProps = {
width: 600,
height: 450,
queriesData: [
{
data: [{ __timestamp: 2, value: 500 }] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: [0, 0],
},
],
formData,
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.headerFormatter.format(500)).toBe('$500');
});
});

View File

@@ -129,8 +129,5 @@ export const subtitleControl: CustomControlItem = {
label: t('Subtitle'),
renderTrigger: true,
description: t('Description text that shows up below your Big Number'),
mapStateToProps: state => ({
value: state.form_data.subheader || state.form_data.subtitle,
}),
},
};

View File

@@ -77,7 +77,9 @@ export type BigNumberVizProps = {
formatTime?: TimeFormatter;
headerFontSize: number;
kickerFontSize?: number;
subheader?: string;
subtitle: string;
subheaderFontSize: number;
subtitleFontSize: number;
showTimestamp?: boolean;
showTrendLine?: boolean;

View File

@@ -734,6 +734,17 @@ const retainQueryModeRequirements = hiddenFormData =>
key => !QUERY_MODE_REQUISITES.has(key),
);
function patchBigNumberTotalFormData(form_data, slice) {
if (
form_data.viz_type === 'big_number_total' &&
!form_data.subtitle &&
slice?.form_data?.subheader
) {
return { ...form_data, subtitle: slice.form_data.subheader };
}
return form_data;
}
function mapStateToProps(state) {
const {
explore,
@@ -768,6 +779,25 @@ function mapStateToProps(state) {
dashboardId = undefined;
}
if (
form_data.viz_type === 'big_number_total' &&
slice?.form_data?.subheader &&
(!controls.subtitle?.value || controls.subtitle.value === '')
) {
controls.subtitle = {
...controls.subtitle,
value: slice.form_data.subheader,
};
if (slice?.form_data?.subheader_font_size) {
controls.subtitle_font_size = {
...controls.subtitle_font_size,
value: slice.form_data.subheader_font_size,
};
}
}
const patchedFormData = patchBigNumberTotalFormData(form_data, slice);
return {
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
datasource,
@@ -789,7 +819,7 @@ function mapStateToProps(state) {
slice,
sliceName: explore.sliceName ?? slice?.slice_name ?? null,
triggerRender: explore.triggerRender,
form_data,
form_data: patchedFormData,
table_name: datasource.table_name,
vizType: form_data.viz_type,
standalone: !!explore.standalone,