/** * 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 { ChartProps, SqlaFormData } from '@superset-ui/core'; import { supersetTheme } from '@apache-superset/core/ui'; import { EchartsTimeseriesChartProps } from '../../../src/types'; import transformProps from '../../../src/Timeseries/transformProps'; import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants'; import { EchartsTimeseriesSeriesType } from '../../../src/Timeseries/types'; describe('Bar Chart X-axis Time Formatting', () => { const baseFormData: SqlaFormData = { ...DEFAULT_FORM_DATA, colorScheme: 'bnbColors', datasource: '3__table', granularity_sqla: '__timestamp', metric: ['Sales', 'Marketing', 'Operations'], groupby: [], viz_type: 'echarts_timeseries_bar', seriesType: EchartsTimeseriesSeriesType.Bar, orientation: 'vertical', }; const timeseriesData = [ { data: [ { Sales: 100, __timestamp: 1609459200000 }, // 2021-01-01 { Marketing: 150, __timestamp: 1612137600000 }, // 2021-02-01 { Operations: 200, __timestamp: 1614556800000 }, // 2021-03-01 ], colnames: ['Sales', 'Marketing', 'Operations', '__timestamp'], coltypes: ['BIGINT', 'BIGINT', 'BIGINT', 'TIMESTAMP'], }, ]; const baseChartPropsConfig = { width: 800, height: 600, queriesData: timeseriesData, theme: supersetTheme, }; describe('Default xAxisTimeFormat', () => { test('should use smart_date as default xAxisTimeFormat', () => { const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: baseFormData, }); const transformedProps = transformProps( chartProps as EchartsTimeseriesChartProps, ); // Check that the x-axis has a formatter applied expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel'); const xAxis = transformedProps.echartOptions.xAxis as any; expect(xAxis.axisLabel).toHaveProperty('formatter'); expect(typeof xAxis.axisLabel.formatter).toBe('function'); }); test('should apply xAxisTimeFormat from DEFAULT_FORM_DATA when not explicitly set', () => { const formDataWithoutTimeFormat = { ...baseFormData, }; delete formDataWithoutTimeFormat.xAxisTimeFormat; const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: formDataWithoutTimeFormat, }); const transformedProps = transformProps( chartProps as EchartsTimeseriesChartProps, ); // Should still have a formatter since DEFAULT_FORM_DATA includes xAxisTimeFormat expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel'); const xAxis = transformedProps.echartOptions.xAxis as any; expect(xAxis.axisLabel).toHaveProperty('formatter'); }); }); describe('Custom xAxisTimeFormat', () => { test('should respect custom xAxisTimeFormat when explicitly set', () => { const customFormData = { ...baseFormData, xAxisTimeFormat: '%Y-%m-%d', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: customFormData, }); const transformedProps = transformProps( chartProps as EchartsTimeseriesChartProps, ); // Verify the formatter function exists and is applied expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel'); const xAxis = transformedProps.echartOptions.xAxis as any; expect(xAxis.axisLabel).toHaveProperty('formatter'); expect(typeof xAxis.axisLabel.formatter).toBe('function'); // The key test is that a formatter exists - the actual formatting is handled by d3-time-format const { formatter } = xAxis.axisLabel; expect(formatter).toBeDefined(); expect(typeof formatter).toBe('function'); }); test('should handle different time format options', () => { const timeFormats = [ '%Y-%m-%d', '%Y/%m/%d', '%m/%d/%Y', '%b %d, %Y', 'smart_date', ]; timeFormats.forEach(timeFormat => { const customFormData = { ...baseFormData, xAxisTimeFormat: timeFormat, }; const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: customFormData, }); const transformedProps = transformProps( chartProps as EchartsTimeseriesChartProps, ); const xAxis = transformedProps.echartOptions.xAxis as any; expect(xAxis.axisLabel).toHaveProperty('formatter'); expect(typeof xAxis.axisLabel.formatter).toBe('function'); }); }); }); describe('Orientation-specific behavior', () => { test('should apply time formatting to x-axis in vertical bar charts', () => { const verticalFormData = { ...baseFormData, orientation: 'vertical', xAxisTimeFormat: '%Y-%m', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: verticalFormData, }); const transformedProps = transformProps( chartProps as EchartsTimeseriesChartProps, ); // In vertical orientation, time should be on x-axis const xAxis = transformedProps.echartOptions.xAxis as any; expect(xAxis.axisLabel).toHaveProperty('formatter'); expect(typeof xAxis.axisLabel.formatter).toBe('function'); }); test('should apply time formatting to y-axis in horizontal bar charts', () => { const horizontalFormData = { ...baseFormData, orientation: 'horizontal', xAxisTimeFormat: '%Y-%m', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: horizontalFormData, }); const transformedProps = transformProps( chartProps as EchartsTimeseriesChartProps, ); // In horizontal orientation, axes are swapped, so time should be on y-axis const yAxis = transformedProps.echartOptions.yAxis as any; expect(yAxis.axisLabel).toHaveProperty('formatter'); expect(typeof yAxis.axisLabel.formatter).toBe('function'); }); }); describe('Integration with existing features', () => { test('should work with axis bounds', () => { const formDataWithBounds = { ...baseFormData, xAxisTimeFormat: '%Y-%m-%d', truncateXAxis: true, xAxisBounds: [null, null] as [number | null, number | null], }; const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: formDataWithBounds, }); const transformedProps = transformProps( chartProps as EchartsTimeseriesChartProps, ); const xAxis = transformedProps.echartOptions.xAxis as any; expect(xAxis.axisLabel).toHaveProperty('formatter'); // The xAxis should be configured with the time formatting expect(transformedProps.echartOptions.xAxis).toBeDefined(); }); test('should work with label rotation', () => { const formDataWithRotation = { ...baseFormData, xAxisTimeFormat: '%Y-%m-%d', xAxisLabelRotation: 45, }; const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: formDataWithRotation, }); const transformedProps = transformProps( chartProps as EchartsTimeseriesChartProps, ); const xAxis = transformedProps.echartOptions.xAxis as any; expect(xAxis.axisLabel).toHaveProperty('formatter'); expect(xAxis.axisLabel).toHaveProperty('rotate', 45); }); test('should maintain time formatting consistency with tooltip', () => { const formDataWithTooltip = { ...baseFormData, xAxisTimeFormat: '%Y-%m-%d', tooltipTimeFormat: '%Y-%m-%d', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: formDataWithTooltip, }); const transformedProps = transformProps( chartProps as EchartsTimeseriesChartProps, ); // Both axis and tooltip should have formatters const xAxis = transformedProps.echartOptions.xAxis as any; expect(xAxis.axisLabel).toHaveProperty('formatter'); expect(transformedProps.xValueFormatter).toBeDefined(); expect(typeof transformedProps.xValueFormatter).toBe('function'); }); }); describe('Regression test for Issue #30373', () => { test('should not be stuck on adaptive formatting', () => { // Test the exact scenario described in the issue const issueFormData = { ...baseFormData, xAxisTimeFormat: '%Y-%m-%d %H:%M:%S', // Non-adaptive format }; const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: issueFormData, }); const transformedProps = transformProps( chartProps as EchartsTimeseriesChartProps, ); // Verify formatter exists - this is the key fix, ensuring xAxisTimeFormat is used const xAxis = transformedProps.echartOptions.xAxis as any; const { formatter } = xAxis.axisLabel; expect(formatter).toBeDefined(); expect(typeof formatter).toBe('function'); // The important part is that the xAxisTimeFormat is being used from formData // The actual formatting is handled by the underlying time formatter }); test('should allow changing from smart_date to other formats', () => { // First create with smart_date (default) const smartDateFormData = { ...baseFormData, xAxisTimeFormat: 'smart_date', }; const smartDateChartProps = new ChartProps({ ...baseChartPropsConfig, formData: smartDateFormData, }); const smartDateProps = transformProps( smartDateChartProps as EchartsTimeseriesChartProps, ); // Then change to a different format const customFormatFormData = { ...baseFormData, xAxisTimeFormat: '%b %Y', }; const customFormatChartProps = new ChartProps({ ...baseChartPropsConfig, formData: customFormatFormData, }); const customFormatProps = transformProps( customFormatChartProps as EchartsTimeseriesChartProps, ); // Both should have formatters - the key is that they're not undefined const smartDateXAxis = smartDateProps.echartOptions.xAxis as any; const customFormatXAxis = customFormatProps.echartOptions.xAxis as any; expect(smartDateXAxis.axisLabel.formatter).toBeDefined(); expect(customFormatXAxis.axisLabel.formatter).toBeDefined(); // Both should be functions that can format time expect(typeof smartDateXAxis.axisLabel.formatter).toBe('function'); expect(typeof customFormatXAxis.axisLabel.formatter).toBe('function'); }); test('should have xAxisTimeFormat in formData by default', () => { // This test specifically verifies our fix - that DEFAULT_FORM_DATA includes xAxisTimeFormat const chartProps = new ChartProps({ ...baseChartPropsConfig, formData: baseFormData, }); expect(chartProps.formData.xAxisTimeFormat).toBeDefined(); expect(chartProps.formData.xAxisTimeFormat).toBe('smart_date'); }); }); describe('Color By X-Axis Feature', () => { const categoricalData = [ { data: [ { category: 'A', value: 100 }, { category: 'B', value: 150 }, { category: 'C', value: 200 }, ], colnames: ['category', 'value'], coltypes: ['STRING', 'BIGINT'], }, ]; test('should apply color by x-axis when enabled with no dimensions', () => { const formData = { ...baseFormData, colorByPrimaryAxis: true, groupby: [], x_axis: 'category', metric: 'value', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, queriesData: categoricalData, formData, }); const transformedProps = transformProps( chartProps as unknown as EchartsTimeseriesChartProps, ); // Should have hidden legend series for each x-axis value const series = transformedProps.echartOptions.series as any[]; expect(series.length).toBeGreaterThan(3); // Original series + hidden legend series // Check that legend data contains x-axis values const legendData = transformedProps.legendData as string[]; expect(legendData).toContain('A'); expect(legendData).toContain('B'); expect(legendData).toContain('C'); // Check that legend items have roundRect icons const legend = transformedProps.echartOptions.legend as any; expect(legend.data).toBeDefined(); expect(Array.isArray(legend.data)).toBe(true); if (legend.data.length > 0 && typeof legend.data[0] === 'object') { expect(legend.data[0].icon).toBe('roundRect'); } }); test('should NOT apply color by x-axis when dimensions are present', () => { const formData = { ...baseFormData, colorByPrimaryAxis: true, groupby: ['region'], x_axis: 'category', metric: 'value', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, queriesData: categoricalData, formData, }); const transformedProps = transformProps( chartProps as unknown as EchartsTimeseriesChartProps, ); // Legend data should NOT contain x-axis values when dimensions exist const legendData = transformedProps.legendData as string[]; // Should use series names, not x-axis values expect(legendData.length).toBeLessThan(10); }); test('should use x-axis values as color keys for consistent colors', () => { const formData = { ...baseFormData, colorByPrimaryAxis: true, groupby: [], x_axis: 'category', metric: 'value', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, queriesData: categoricalData, formData, }); const transformedProps = transformProps( chartProps as unknown as EchartsTimeseriesChartProps, ); const series = transformedProps.echartOptions.series as any[]; // Find the data series (not the hidden legend series) const dataSeries = series.find( s => s.data && s.data.length > 0 && s.type === 'bar', ); expect(dataSeries).toBeDefined(); // Check that data points have individual itemStyle with colors if (dataSeries && Array.isArray(dataSeries.data)) { const dataPoint = dataSeries.data[0]; if ( dataPoint && typeof dataPoint === 'object' && 'itemStyle' in dataPoint ) { expect(dataPoint.itemStyle).toBeDefined(); expect(dataPoint.itemStyle.color).toBeDefined(); } } }); test('should disable legend selection when color by x-axis is enabled', () => { const formData = { ...baseFormData, colorByPrimaryAxis: true, groupby: [], x_axis: 'category', metric: 'value', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, queriesData: categoricalData, formData, }); const transformedProps = transformProps( chartProps as unknown as EchartsTimeseriesChartProps, ); const legend = transformedProps.echartOptions.legend as any; expect(legend.selectedMode).toBe(false); expect(legend.selector).toBe(false); }); test('should work without stacking enabled', () => { const formData = { ...baseFormData, colorByPrimaryAxis: true, groupby: [], stack: null, x_axis: 'category', metric: 'value', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, queriesData: categoricalData, formData, }); const transformedProps = transformProps( chartProps as unknown as EchartsTimeseriesChartProps, ); // Should still create legend with x-axis values const legendData = transformedProps.legendData as string[]; expect(legendData.length).toBeGreaterThan(0); expect(legendData).toContain('A'); }); test('should handle when colorByPrimaryAxis is disabled', () => { const formData = { ...baseFormData, colorByPrimaryAxis: false, groupby: [], x_axis: 'category', metric: 'value', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, queriesData: categoricalData, formData, }); const transformedProps = transformProps( chartProps as unknown as EchartsTimeseriesChartProps, ); // Legend should not be disabled when feature is off const legend = transformedProps.echartOptions.legend as any; expect(legend.selectedMode).not.toBe(false); }); test('should use category axis (Y) as color key for horizontal bar charts', () => { const formData = { ...baseFormData, colorByPrimaryAxis: true, groupby: [], orientation: 'horizontal', x_axis: 'category', metric: 'value', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, queriesData: categoricalData, formData, }); const transformedProps = transformProps( chartProps as unknown as EchartsTimeseriesChartProps, ); // Legend should contain category values (A, B, C), not numeric values const legendData = transformedProps.legendData as string[]; expect(legendData).toContain('A'); expect(legendData).toContain('B'); expect(legendData).toContain('C'); }); test('should deduplicate legend entries when x-axis has repeated values', () => { const repeatedData = [ { data: [ { category: 'A', value: 100 }, { category: 'A', value: 200 }, { category: 'B', value: 150 }, ], colnames: ['category', 'value'], coltypes: ['STRING', 'BIGINT'], }, ]; const formData = { ...baseFormData, colorByPrimaryAxis: true, groupby: [], x_axis: 'category', metric: 'value', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, queriesData: repeatedData, formData, }); const transformedProps = transformProps( chartProps as unknown as EchartsTimeseriesChartProps, ); const legendData = transformedProps.legendData as string[]; // 'A' should appear only once despite being in the data twice expect(legendData.filter(v => v === 'A').length).toBe(1); expect(legendData).toContain('B'); }); test('should create exactly one hidden legend series per unique category', () => { const formData = { ...baseFormData, colorByPrimaryAxis: true, groupby: [], x_axis: 'category', metric: 'value', }; const chartProps = new ChartProps({ ...baseChartPropsConfig, queriesData: categoricalData, formData, }); const transformedProps = transformProps( chartProps as unknown as EchartsTimeseriesChartProps, ); const series = transformedProps.echartOptions.series as any[]; const hiddenSeries = series.filter( s => s.type === 'line' && Array.isArray(s.data) && s.data.length === 0, ); // One hidden series per unique category (A, B, C) expect(hiddenSeries.length).toBe(3); }); }); });