mirror of
https://github.com/apache/superset.git
synced 2026-09-09 00:34:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
811137083a |
@@ -525,9 +525,10 @@ export default function transformProps(
|
||||
// bug in Echarts - `stackStrategy: 'all'` doesn't work with nulls, so we cast them to 0
|
||||
series.push({
|
||||
...transformedSeries,
|
||||
data: (transformedSeries.data as any).map(
|
||||
(row: [string | number, number]) => [row[0], row[1] ?? 0],
|
||||
),
|
||||
data: (Array.isArray(transformedSeries.data)
|
||||
? transformedSeries.data
|
||||
: []
|
||||
).map((row: [string | number, number]) => [row[0], row[1] ?? 0]),
|
||||
});
|
||||
} else {
|
||||
series.push(transformedSeries);
|
||||
@@ -1040,10 +1041,10 @@ export default function transformProps(
|
||||
// For axis tooltips, prefer axisValue/axisValueLabel which contains the full label
|
||||
// even when the axis label is visually truncated
|
||||
const xValue: number = richTooltip
|
||||
? (params[0].axisValue ??
|
||||
params[0].axisValueLabel ??
|
||||
params[0].value[xIndex])
|
||||
: params.value[xIndex];
|
||||
? (params[0]?.axisValue ??
|
||||
params[0]?.axisValueLabel ??
|
||||
params[0]?.value?.[xIndex])
|
||||
: params.value?.[xIndex];
|
||||
const forecastValue: CallbackDataParams[] = richTooltip
|
||||
? params
|
||||
: [params];
|
||||
|
||||
@@ -75,31 +75,38 @@ export const getBaselineSeriesForStream = (
|
||||
series: [string | number, number][][],
|
||||
seriesType: EchartsTimeseriesSeriesType,
|
||||
) => {
|
||||
const seriesLength = series[0].length;
|
||||
// Series data can be empty or ragged (unequal lengths, null rows) when a
|
||||
// metric column is missing or a series is all-null; index defensively so a
|
||||
// stream-stacked chart doesn't crash while computing the baseline.
|
||||
const seriesLength = series[0]?.length ?? 0;
|
||||
const baselineSeriesDelta: [string | number, number][] = Array.from(
|
||||
{ length: seriesLength },
|
||||
() => [0, 0],
|
||||
);
|
||||
const getVal = (value: number | null) => value ?? 0;
|
||||
const getVal = (value: number | null | undefined) => value ?? 0;
|
||||
for (let i = 0; i < seriesLength; i += 1) {
|
||||
let seriesSum = 0;
|
||||
let weightedSeriesSum = 0;
|
||||
for (let j = 0; j < series.length; j += 1) {
|
||||
const delta =
|
||||
i > 0
|
||||
? getVal(series[j][i][1]) - getVal(series[j][i - 1][1])
|
||||
: getVal(series[j][i][1]);
|
||||
? getVal(series[j]?.[i]?.[1]) - getVal(series[j]?.[i - 1]?.[1])
|
||||
: getVal(series[j]?.[i]?.[1]);
|
||||
let deltaPrev = 0;
|
||||
for (let k = 1; k < j - 1; k += 1) {
|
||||
deltaPrev +=
|
||||
i > 0
|
||||
? getVal(series[k][i][1]) - getVal(series[k][i - 1][1])
|
||||
: getVal(series[k][i][1]);
|
||||
? getVal(series[k]?.[i]?.[1]) - getVal(series[k]?.[i - 1]?.[1])
|
||||
: getVal(series[k]?.[i]?.[1]);
|
||||
}
|
||||
weightedSeriesSum += (0.5 * delta + deltaPrev) * getVal(series[j][i][1]);
|
||||
seriesSum += getVal(series[j][i][1]);
|
||||
weightedSeriesSum +=
|
||||
(0.5 * delta + deltaPrev) * getVal(series[j]?.[i]?.[1]);
|
||||
seriesSum += getVal(series[j]?.[i]?.[1]);
|
||||
}
|
||||
baselineSeriesDelta[i] = [series[0][i][0], -weightedSeriesSum / seriesSum];
|
||||
baselineSeriesDelta[i] = [
|
||||
series[0][i][0],
|
||||
seriesSum === 0 ? 0 : -weightedSeriesSum / seriesSum,
|
||||
];
|
||||
}
|
||||
const baselineSeries = baselineSeriesDelta.reduce<
|
||||
[string | number, number][]
|
||||
@@ -430,6 +437,11 @@ export function transformSeries(
|
||||
return '';
|
||||
}
|
||||
const { value, dataIndex, seriesIndex, seriesName } = params;
|
||||
// A rendered point can be a bare null/undefined (empty or all-null
|
||||
// series) rather than a [x, y] tuple; bail out instead of indexing it.
|
||||
if (!Array.isArray(value)) {
|
||||
return '';
|
||||
}
|
||||
const numericValue = isHorizontal ? value[0] : value[1];
|
||||
const isSelectedLegend = !legendState || legendState[seriesName];
|
||||
const isAreaExpand = stack === StackControlsValue.Expand;
|
||||
@@ -687,7 +699,12 @@ export function transformTimeseriesAnnotation(
|
||||
const { hideLine, name, opacity, showMarkers, style, width, color } = layer;
|
||||
const result = annotationData[name];
|
||||
const isHorizontal = orientation === OrientationType.Horizontal;
|
||||
const { records } = result;
|
||||
// `result` can be undefined when a Timeseries annotation layer is shown but
|
||||
// its backing data is missing (source deleted/renamed, filtered to empty, or
|
||||
// the annotation query returned nothing). Guard against it rather than
|
||||
// destructuring directly, mirroring `extractRecordAnnotations`, so the whole
|
||||
// chart doesn't fail to render. (regression from #34709)
|
||||
const records = result?.records;
|
||||
if (records) {
|
||||
const data = records.map(record => {
|
||||
const keys = Object.keys(record);
|
||||
|
||||
@@ -62,7 +62,13 @@ export const extractForecastValuesFromTooltipParams = (
|
||||
params.forEach(param => {
|
||||
const { marker, seriesId, value, color } = param;
|
||||
const context = extractForecastSeriesContext(seriesId);
|
||||
const numericValue = isHorizontal ? value[0] : value[1];
|
||||
// `value` may be a bare null/undefined for empty or all-null series;
|
||||
// guard before indexing so the tooltip doesn't throw.
|
||||
const numericValue = Array.isArray(value)
|
||||
? isHorizontal
|
||||
? value[0]
|
||||
: value[1]
|
||||
: undefined;
|
||||
if (typeof numericValue === 'number') {
|
||||
if (!(context.name in values))
|
||||
values[context.name] = {
|
||||
|
||||
@@ -1053,13 +1053,15 @@ export function extractTooltipKeys(
|
||||
if (richTooltip && tooltipSortByMetric) {
|
||||
return forecastValue
|
||||
.slice()
|
||||
.sort((a, b) => b.data[yIndex] - a.data[yIndex])
|
||||
.sort((a, b) => (b.data?.[yIndex] ?? 0) - (a.data?.[yIndex] ?? 0))
|
||||
.map(value => value[TOOLTIP_SERIES_KEY]);
|
||||
}
|
||||
if (richTooltip) {
|
||||
return forecastValue.map(s => s[TOOLTIP_SERIES_KEY]);
|
||||
}
|
||||
return [forecastValue[0][TOOLTIP_SERIES_KEY]];
|
||||
// Non-rich (item) tooltips can fire with an empty params array when the
|
||||
// hovered point has no series data; avoid indexing into an empty array.
|
||||
return forecastValue.length ? [forecastValue[0][TOOLTIP_SERIES_KEY]] : [];
|
||||
}
|
||||
|
||||
export function groupData(data: DataRecord[], by?: string | null) {
|
||||
|
||||
@@ -267,6 +267,30 @@ test('extractForecastValuesFromTooltipParams should extract valid values', () =>
|
||||
});
|
||||
});
|
||||
|
||||
// Regression (H3/sc-114915): all-null or empty series can hand the tooltip a
|
||||
// bare null/undefined `value` instead of a [x, y] tuple. Indexing it threw
|
||||
// "Cannot read properties of null (reading '1')" and killed the render.
|
||||
test('extractForecastValuesFromTooltipParams tolerates null/undefined values', () => {
|
||||
expect(() =>
|
||||
extractForecastValuesFromTooltipParams([
|
||||
{ marker: '<img>', seriesId: 'nullish', value: null },
|
||||
{ marker: '<img>', seriesId: 'undef', value: undefined },
|
||||
{ marker: '<img>', seriesId: 'ok', value: [0, 10] },
|
||||
]),
|
||||
).not.toThrow();
|
||||
expect(
|
||||
extractForecastValuesFromTooltipParams([
|
||||
{ marker: '<img>', seriesId: 'nullish', value: null },
|
||||
{ marker: '<img>', seriesId: 'ok', value: [0, 10] },
|
||||
]),
|
||||
).toEqual({
|
||||
ok: {
|
||||
marker: '<img>',
|
||||
observation: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const formatter = getNumberFormatter(NumberFormats.INTEGER);
|
||||
|
||||
test('formatForecastTooltipSeries should apply format to value', () => {
|
||||
|
||||
@@ -1646,3 +1646,20 @@ test('extractTooltipKeys with non-rich tooltip', () => {
|
||||
const result = extractTooltipKeys(forecastValue, 1, false, false);
|
||||
expect(result).toEqual(['foo']);
|
||||
});
|
||||
|
||||
// Regression (H3/sc-114915): an item tooltip firing with no series data passed
|
||||
// an empty params array, and `forecastValue[0][TOOLTIP_SERIES_KEY]` threw
|
||||
// "Cannot read properties of undefined". It should return an empty list.
|
||||
test('extractTooltipKeys with non-rich tooltip and empty params', () => {
|
||||
expect(() => extractTooltipKeys([], 1, false, false)).not.toThrow();
|
||||
expect(extractTooltipKeys([], 1, false, false)).toEqual([]);
|
||||
});
|
||||
|
||||
// Sorting must tolerate series whose `data` is missing (all-null series).
|
||||
test('extractTooltipKeys sorts by metric even when some data is missing', () => {
|
||||
const withMissing = [
|
||||
{ seriesId: 'foo' },
|
||||
{ data: [0, 2], seriesId: 'bar' },
|
||||
] as any[];
|
||||
expect(() => extractTooltipKeys(withMissing, 1, true, true)).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -31,8 +31,9 @@ import {
|
||||
TimeseriesDataRecord,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { OrientationType } from '../../src';
|
||||
import { EchartsTimeseriesSeriesType, OrientationType } from '../../src';
|
||||
import {
|
||||
getBaselineSeriesForStream,
|
||||
transformEventAnnotation,
|
||||
transformFormulaAnnotation,
|
||||
transformIntervalAnnotation,
|
||||
@@ -422,4 +423,70 @@ describe('transformTimeseriesAnnotation', () => {
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression: #34709 replaced the `Array.isArray(result)` guard with an
|
||||
// unguarded `const { records } = result`, so a shown Timeseries annotation
|
||||
// layer whose backing data is missing crashed the entire chart render with
|
||||
// "Cannot read properties of undefined (reading 'records')" (sc-114915).
|
||||
test('does not throw and returns no series when annotation data is missing', () => {
|
||||
let series;
|
||||
expect(() => {
|
||||
series = transformTimeseriesAnnotation(
|
||||
mockTimeseriesAnnotationLayer,
|
||||
1,
|
||||
mockData,
|
||||
{}, // annotationData has no entry for this shown layer
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
);
|
||||
}).not.toThrow();
|
||||
expect(series).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not throw when annotation result exists but has no records', () => {
|
||||
let series;
|
||||
expect(() => {
|
||||
series = transformTimeseriesAnnotation(
|
||||
mockTimeseriesAnnotationLayer,
|
||||
1,
|
||||
mockData,
|
||||
{ 'Timeseries annotation layer': {} } as AnnotationData,
|
||||
CategoricalColorNamespace.getScale(''),
|
||||
);
|
||||
}).not.toThrow();
|
||||
expect(series).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBaselineSeriesForStream', () => {
|
||||
// Regression (H4/sc-114915): ragged or empty series data drove numeric
|
||||
// indexing into a null/undefined array (e.g. `series[j][i][1]`), throwing
|
||||
// "Cannot read properties of null (reading '<n>')" during stream-stack render.
|
||||
test('does not throw when series data is empty', () => {
|
||||
expect(() =>
|
||||
getBaselineSeriesForStream([], EchartsTimeseriesSeriesType.Line),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('does not throw when series have ragged/missing rows', () => {
|
||||
const ragged = [
|
||||
[
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 3],
|
||||
],
|
||||
[[0, 5]], // shorter series: missing later rows
|
||||
] as [string | number, number][][];
|
||||
let baseline;
|
||||
expect(() => {
|
||||
baseline = getBaselineSeriesForStream(
|
||||
ragged,
|
||||
EchartsTimeseriesSeriesType.Line,
|
||||
);
|
||||
}).not.toThrow();
|
||||
expect((baseline as any).data).toHaveLength(3);
|
||||
// No NaN leaks into the baseline coordinates fed to ECharts.
|
||||
(baseline as any).data.forEach((point: [string | number, number]) => {
|
||||
expect(Number.isNaN(point[1])).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user