mirror of
https://github.com/apache/superset.git
synced 2026-07-12 01:35:36 +00:00
Adopts #31765 (original by @gerbermichi), splitting the time-grain formatting fix out from the i18n change and breaking-change concerns raised on that PR. Time-series and mixed time-series tooltips now honor the chart's time grain (and any dashboard-level override delivered via extra_form_data) when no explicit tooltip time format is set, matching the behavior the x-axis already applies via getSmartDateFormatter. Tooltips read grain-appropriate labels such as 'Jan 2021' (month), '2021 Q1' (quarter), '2021' (year), and weekly date ranges. An explicit custom tooltip format always wins. The original PR threaded the grain through getTimeFormatter using the SMART_DATE_VERBOSE_ID format id, which produced the literal string 'smart_date_verbose' because getTimeFormatter treats the id as a d3 format string when a granularity is supplied. This reworks the tooltip path to use the granularity formatter (getTimeFormatter(undefined, grain)) instead, with unit-test coverage proving the rendered output for each grain. Adds an UPDATING.md note per villebro's request. Co-Authored-By: Michael Gerber <michael.gerber@nxt.engineering> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
/**
|
|
* 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 {
|
|
NumberFormats,
|
|
SMART_DATE_ID,
|
|
SMART_DATE_VERBOSE_ID,
|
|
TimeFormatter,
|
|
TimeGranularity,
|
|
} from '@superset-ui/core';
|
|
import {
|
|
getPercentFormatter,
|
|
getTooltipTimeFormatter,
|
|
getXAxisFormatter,
|
|
} from '../../src/utils/formatters';
|
|
|
|
test('getPercentFormatter should format as percent if no format is specified', () => {
|
|
const value = 0.6;
|
|
expect(getPercentFormatter().format(value)).toEqual('60%');
|
|
});
|
|
|
|
test('getPercentFormatter should format as percent if SMART_NUMBER is specified', () => {
|
|
const value = 0.6;
|
|
expect(getPercentFormatter(NumberFormats.SMART_NUMBER).format(value)).toEqual(
|
|
'60%',
|
|
);
|
|
});
|
|
|
|
test('getPercentFormatter should format using a provided format', () => {
|
|
const value = 0.6;
|
|
expect(
|
|
getPercentFormatter(NumberFormats.PERCENT_2_POINT).format(value),
|
|
).toEqual('60.00%');
|
|
});
|
|
|
|
test('getXAxisFormatter should return smart date formatter for SMART_DATE_ID format', () => {
|
|
const formatter = getXAxisFormatter(SMART_DATE_ID);
|
|
expect(formatter).toBeDefined();
|
|
expect(formatter).toBeInstanceOf(TimeFormatter);
|
|
expect((formatter as TimeFormatter).id).toBe(SMART_DATE_ID);
|
|
});
|
|
|
|
test('getXAxisFormatter should return smart date formatter for undefined format', () => {
|
|
const formatter = getXAxisFormatter();
|
|
expect(formatter).toBeDefined();
|
|
expect(formatter).toBeInstanceOf(TimeFormatter);
|
|
expect((formatter as TimeFormatter).id).toBe(SMART_DATE_ID);
|
|
});
|
|
|
|
test('getXAxisFormatter should return custom time formatter for custom format', () => {
|
|
const customFormat = '%Y-%m-%d';
|
|
const formatter = getXAxisFormatter(customFormat);
|
|
expect(formatter).toBeDefined();
|
|
expect(formatter).toBeInstanceOf(TimeFormatter);
|
|
expect((formatter as TimeFormatter).id).toBe(customFormat);
|
|
});
|
|
|
|
test('getXAxisFormatter smart date formatter should be returned and not undefined', () => {
|
|
const formatter = getXAxisFormatter(SMART_DATE_ID);
|
|
expect(formatter).toBeDefined();
|
|
expect(formatter).toBeInstanceOf(TimeFormatter);
|
|
expect((formatter as TimeFormatter).id).toBe(SMART_DATE_ID);
|
|
|
|
const undefinedFormatter = getXAxisFormatter(undefined);
|
|
expect(undefinedFormatter).toBeDefined();
|
|
expect(undefinedFormatter).toBeInstanceOf(TimeFormatter);
|
|
expect((undefinedFormatter as TimeFormatter).id).toBe(SMART_DATE_ID);
|
|
|
|
const emptyFormatter = getXAxisFormatter();
|
|
expect(emptyFormatter).toBeDefined();
|
|
expect(emptyFormatter).toBeInstanceOf(TimeFormatter);
|
|
expect((emptyFormatter as TimeFormatter).id).toBe(SMART_DATE_ID);
|
|
});
|
|
|
|
test('getXAxisFormatter time grain aware formatter should prevent millisecond and timestamp formats', () => {
|
|
const formatter = getXAxisFormatter(SMART_DATE_ID, TimeGranularity.MONTH);
|
|
|
|
// Test that dates with milliseconds don't show millisecond format
|
|
const dateWithMs = new Date('2025-03-15T21:13:32.389Z');
|
|
const result = (formatter as TimeFormatter).format(dateWithMs);
|
|
expect(result).not.toContain('.389ms');
|
|
expect(result).not.toMatch(/\.\d+ms/);
|
|
expect(result).not.toContain('PM');
|
|
expect(result).not.toContain('AM');
|
|
expect(result).not.toMatch(/\d{1,2}:\d{2}/); // No time format
|
|
});
|
|
|
|
test('getXAxisFormatter time grain aware formatting should prevent problematic formats', () => {
|
|
// Test that time grain aware formatter prevents the specific issues we solved
|
|
const monthFormatter = getXAxisFormatter(
|
|
SMART_DATE_ID,
|
|
TimeGranularity.MONTH,
|
|
);
|
|
const yearFormatter = getXAxisFormatter(SMART_DATE_ID, TimeGranularity.YEAR);
|
|
const dayFormatter = getXAxisFormatter(SMART_DATE_ID, TimeGranularity.DAY);
|
|
|
|
// Test dates that previously caused issues
|
|
const problematicDates = [
|
|
new Date('2025-03-15T21:13:32.389Z'), // Had .389ms issue
|
|
new Date('2025-04-01T02:30:00.000Z'), // Timezone edge case
|
|
new Date('2025-07-01T00:00:00.000Z'), // Month boundary
|
|
];
|
|
|
|
problematicDates.forEach(date => {
|
|
// Month formatter should not show milliseconds or PM/AM
|
|
const monthResult = (monthFormatter as TimeFormatter).format(date);
|
|
expect(monthResult).not.toMatch(/\.\d+ms/);
|
|
expect(monthResult).not.toMatch(/PM|AM/);
|
|
expect(monthResult).not.toMatch(/\d{1,2}:\d{2}:\d{2}/);
|
|
|
|
// Year formatter should not show milliseconds or PM/AM
|
|
const yearResult = (yearFormatter as TimeFormatter).format(date);
|
|
expect(yearResult).not.toMatch(/\.\d+ms/);
|
|
expect(yearResult).not.toMatch(/PM|AM/);
|
|
expect(yearResult).not.toMatch(/\d{1,2}:\d{2}:\d{2}/);
|
|
|
|
// Day formatter should not show milliseconds or seconds
|
|
const dayResult = (dayFormatter as TimeFormatter).format(date);
|
|
expect(dayResult).not.toMatch(/\.\d+ms/);
|
|
expect(dayResult).not.toMatch(/:\d{2}:\d{2}/); // No seconds
|
|
});
|
|
});
|
|
|
|
test('getXAxisFormatter time grain parameter should be passed correctly', () => {
|
|
// Test that formatter with time grain is different from formatter without
|
|
const formatterWithGrain = getXAxisFormatter(
|
|
SMART_DATE_ID,
|
|
TimeGranularity.MONTH,
|
|
);
|
|
const formatterWithoutGrain = getXAxisFormatter(SMART_DATE_ID);
|
|
|
|
expect(formatterWithGrain).toBeDefined();
|
|
expect(formatterWithoutGrain).toBeDefined();
|
|
expect(formatterWithGrain).toBeInstanceOf(TimeFormatter);
|
|
expect(formatterWithoutGrain).toBeInstanceOf(TimeFormatter);
|
|
|
|
// Both should be valid formatters
|
|
const testDate = new Date('2025-04-15T12:30:45.789Z');
|
|
const resultWithGrain = (formatterWithGrain as TimeFormatter).format(
|
|
testDate,
|
|
);
|
|
const resultWithoutGrain = (formatterWithoutGrain as TimeFormatter).format(
|
|
testDate,
|
|
);
|
|
|
|
expect(typeof resultWithGrain).toBe('string');
|
|
expect(typeof resultWithoutGrain).toBe('string');
|
|
expect(resultWithGrain.length).toBeGreaterThan(0);
|
|
expect(resultWithoutGrain.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('getXAxisFormatter without time grain should use standard smart date behavior', () => {
|
|
const standardFormatter = getXAxisFormatter(SMART_DATE_ID);
|
|
const timeGrainFormatter = getXAxisFormatter(SMART_DATE_ID, undefined);
|
|
|
|
// Both should be equivalent when no time grain is provided
|
|
expect(standardFormatter).toBeDefined();
|
|
expect(timeGrainFormatter).toBeDefined();
|
|
|
|
// Test with a date that has time components
|
|
const testDate = new Date('2025-01-01T00:00:00.000Z');
|
|
const standardResult = (standardFormatter as TimeFormatter).format(testDate);
|
|
const timeGrainResult = (timeGrainFormatter as TimeFormatter).format(
|
|
testDate,
|
|
);
|
|
|
|
expect(standardResult).toBe(timeGrainResult);
|
|
});
|
|
|
|
// Regression tests for echarts-timeseries-epoch-x-axis-labels investigation.
|
|
// The bug report was that temporal x-axis labels could render as "NaN"
|
|
// in some edge cases that we could not reproduce locally. The tests below
|
|
// lock in the current behavior of the formatters so that a future refactor
|
|
// surfaces any change in contract.
|
|
|
|
test('getTooltipTimeFormatter returns a TimeFormatter with SMART_DATE_VERBOSE id for SMART_DATE_ID', () => {
|
|
const formatter = getTooltipTimeFormatter(SMART_DATE_ID);
|
|
expect(formatter).toBeInstanceOf(TimeFormatter);
|
|
expect((formatter as TimeFormatter).id).toBe(SMART_DATE_VERBOSE_ID);
|
|
});
|
|
|
|
test('getTooltipTimeFormatter returns a TimeFormatter for a custom format string', () => {
|
|
const customFormat = '%Y-%m-%d %H:%M';
|
|
const formatter = getTooltipTimeFormatter(customFormat);
|
|
expect(formatter).toBeInstanceOf(TimeFormatter);
|
|
expect((formatter as TimeFormatter).id).toBe(customFormat);
|
|
});
|
|
|
|
test('getTooltipTimeFormatter falls back to the String constructor when no format is supplied', () => {
|
|
expect(getTooltipTimeFormatter()).toBe(String);
|
|
expect(getTooltipTimeFormatter(undefined)).toBe(String);
|
|
});
|
|
|
|
test('getTooltipTimeFormatter respects the time grain for the SMART_DATE path', () => {
|
|
// With a time grain active and no explicit format, the tooltip should read
|
|
// grain-appropriate labels rather than a raw timestamp. UTC-based dates keep
|
|
// the assertions deterministic across CI/developer timezones.
|
|
const date = new Date(Date.UTC(2021, 0, 7));
|
|
|
|
const dayFormatter = getTooltipTimeFormatter(
|
|
SMART_DATE_ID,
|
|
TimeGranularity.DAY,
|
|
) as TimeFormatter;
|
|
expect(dayFormatter.format(date)).toEqual('2021-01-07');
|
|
|
|
const weekFormatter = getTooltipTimeFormatter(
|
|
SMART_DATE_ID,
|
|
TimeGranularity.WEEK,
|
|
) as TimeFormatter;
|
|
expect(weekFormatter.format(date)).toEqual('2021-01-07 — 2021-01-13');
|
|
|
|
const monthFormatter = getTooltipTimeFormatter(
|
|
SMART_DATE_ID,
|
|
TimeGranularity.MONTH,
|
|
) as TimeFormatter;
|
|
expect(monthFormatter.format(date)).toEqual(expect.stringContaining('Jan'));
|
|
expect(monthFormatter.format(date)).toEqual(expect.stringContaining('2021'));
|
|
|
|
const quarterFormatter = getTooltipTimeFormatter(
|
|
SMART_DATE_ID,
|
|
TimeGranularity.QUARTER,
|
|
) as TimeFormatter;
|
|
expect(quarterFormatter.format(date)).toEqual(expect.stringContaining('Q1'));
|
|
expect(quarterFormatter.format(date)).toEqual(
|
|
expect.stringContaining('2021'),
|
|
);
|
|
|
|
const yearFormatter = getTooltipTimeFormatter(
|
|
SMART_DATE_ID,
|
|
TimeGranularity.YEAR,
|
|
) as TimeFormatter;
|
|
expect(yearFormatter.format(date)).toEqual('2021');
|
|
});
|
|
|
|
test('getTooltipTimeFormatter applies the time grain even without an explicit format', () => {
|
|
const date = new Date(Date.UTC(2021, 0, 7));
|
|
const monthFormatter = getTooltipTimeFormatter(
|
|
undefined,
|
|
TimeGranularity.MONTH,
|
|
) as TimeFormatter;
|
|
expect(monthFormatter).toBeInstanceOf(TimeFormatter);
|
|
expect(monthFormatter.format(date)).toEqual(expect.stringContaining('2021'));
|
|
});
|
|
|
|
test('getTooltipTimeFormatter honors an explicit custom format over the time grain', () => {
|
|
// A user-pinned format must win, so the grain does not turn a single date
|
|
// into a range or otherwise override the requested format.
|
|
const formatter = getTooltipTimeFormatter(
|
|
'%Y-%m-%d',
|
|
TimeGranularity.YEAR,
|
|
) as TimeFormatter;
|
|
expect(formatter).toBeInstanceOf(TimeFormatter);
|
|
expect(formatter.id).toBe('%Y-%m-%d');
|
|
expect(formatter.format(new Date(Date.UTC(2021, 0, 7)))).toEqual(
|
|
'2021-01-07',
|
|
);
|
|
});
|
|
|
|
test('getXAxisFormatter produces stable SMART_DATE output for a valid Date', () => {
|
|
// Documents the current happy-path output format so unexpected changes are
|
|
// caught during review.
|
|
const formatter = getXAxisFormatter(SMART_DATE_ID) as TimeFormatter;
|
|
const result = formatter.format(new Date('2025-01-15T00:00:00.000Z'));
|
|
expect(typeof result).toBe('string');
|
|
expect(result).not.toMatch(/NaN/);
|
|
expect(result.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('getXAxisFormatter returns a string for an Invalid Date without throwing', () => {
|
|
// If a caller ever passes an Invalid Date (the originally-suspected cause
|
|
// of epoch-ms axis labels showing NaN in echarts), the formatter must
|
|
// still return a string instead of throwing, so echarts does not blow up
|
|
// the chart render. The *content* of that string is format-dependent and
|
|
// intentionally not asserted here — only that it is a string.
|
|
const formatter = getXAxisFormatter(SMART_DATE_ID) as TimeFormatter;
|
|
const invalid = new Date(Number.NaN);
|
|
expect(() => formatter.format(invalid)).not.toThrow();
|
|
expect(typeof formatter.format(invalid)).toBe('string');
|
|
|
|
const customFormatter = getXAxisFormatter('%Y-%m-%d') as TimeFormatter;
|
|
expect(() => customFormatter.format(invalid)).not.toThrow();
|
|
expect(typeof customFormatter.format(invalid)).toBe('string');
|
|
});
|