diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts b/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts index f410c4479a..8ae89efbf6 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts @@ -24,7 +24,7 @@ import { QueryColumn, DatasourceType, } from '@superset-ui/core'; -import { ColumnMeta } from './types'; +import { ColumnMeta, SortSeriesData, SortSeriesType } from './types'; // eslint-disable-next-line import/prefer-default-export export const TIME_FILTER_LABELS = { @@ -57,3 +57,21 @@ export const QueryModeLabel = { [QueryMode.aggregate]: t('Aggregate'), [QueryMode.raw]: t('Raw records'), }; + +export const DEFAULT_SORT_SERIES_DATA: SortSeriesData = { + sort_series_type: SortSeriesType.Sum, + sort_series_ascending: false, +}; + +export const SORT_SERIES_CHOICES = [ + [SortSeriesType.Name, t('Category name')], + [SortSeriesType.Sum, t('Total value')], + [SortSeriesType.Min, t('Minimum value')], + [SortSeriesType.Max, t('Maximum value')], + [SortSeriesType.Avg, t('Average value')], +]; + +export const DEFAULT_XAXIS_SORT_SERIES_DATA: SortSeriesData = { + sort_series_type: SortSeriesType.Name, + sort_series_ascending: true, +}; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/echartsTimeSeriesQuery.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/echartsTimeSeriesQuery.tsx index cd58780d89..53c9aa7447 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/echartsTimeSeriesQuery.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/echartsTimeSeriesQuery.tsx @@ -20,8 +20,10 @@ import { hasGenericChartAxes, t } from '@superset-ui/core'; import { ControlPanelSectionConfig, ControlSetRow } from '../types'; import { contributionModeControl, - xAxisSortControl, xAxisSortAscControl, + xAxisSortControl, + xAxisSortSeriesAscendingControl, + xAxisSortSeriesControl, } from '../shared-controls'; const controlsWithoutXAxis: ControlSetRow[] = [ @@ -55,6 +57,8 @@ export const echartsTimeSeriesQueryWithXAxisSort: ControlPanelSectionConfig = { [hasGenericChartAxes ? 'time_grain_sqla' : null], [hasGenericChartAxes ? xAxisSortControl : null], [hasGenericChartAxes ? xAxisSortAscControl : null], + [hasGenericChartAxes ? xAxisSortSeriesControl : null], + [hasGenericChartAxes ? xAxisSortSeriesAscendingControl : null], ...controlsWithoutXAxis, ], }; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx index 5ac303f54d..28fbfb876c 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/customControls.tsx @@ -34,6 +34,10 @@ import { isDataset, } from '../types'; import { isTemporalColumn } from '../utils'; +import { + DEFAULT_XAXIS_SORT_SERIES_DATA, + SORT_SERIES_CHOICES, +} from '../constants'; export const contributionModeControl = { name: 'contributionMode', @@ -59,6 +63,19 @@ const xAxisSortVisibility = ({ controls }: { controls: ControlStateMapping }) => Array.isArray(controls?.groupby?.value) && controls.groupby.value.length === 0; +const xAxisMultiSortVisibility = ({ + controls, +}: { + controls: ControlStateMapping; +}) => + isDefined(controls?.x_axis?.value) && + !isTemporalColumn( + getColumnLabel(controls?.x_axis?.value as QueryFormColumn), + controls?.datasource?.datasource, + ) && + Array.isArray(controls?.groupby?.value) && + !!controls.groupby.value.length; + export const xAxisSortControl = { name: 'x_axis_sort', config: { @@ -125,3 +142,35 @@ export const xAxisSortAscControl = { visibility: xAxisSortVisibility, }, }; + +export const xAxisSortSeriesControl = { + name: 'x_axis_sort_series', + config: { + type: 'SelectControl', + freeForm: false, + label: (state: ControlPanelState) => + state.form_data?.orientation === 'horizontal' + ? t('Y-Axis Sort By') + : t('X-Axis Sort By'), + choices: SORT_SERIES_CHOICES, + default: DEFAULT_XAXIS_SORT_SERIES_DATA.sort_series_type, + renderTrigger: true, + description: t('Decides which measure to sort the base axis by.'), + visibility: xAxisMultiSortVisibility, + }, +}; + +export const xAxisSortSeriesAscendingControl = { + name: 'x_axis_sort_series_ascending', + config: { + type: 'CheckboxControl', + label: (state: ControlPanelState) => + state.form_data?.orientation === 'horizontal' + ? t('Y-Axis Sort Ascending') + : t('X-Axis Sort Ascending'), + default: DEFAULT_XAXIS_SORT_SERIES_DATA.sort_series_ascending, + description: t('Whether to sort ascending or descending on the base Axis.'), + renderTrigger: true, + visibility: xAxisMultiSortVisibility, + }, +}; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index d4e91246ab..67582523bc 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -481,3 +481,16 @@ export function isQueryResponse( ): datasource is QueryResponse { return !!datasource && 'results' in datasource && 'sql' in datasource; } + +export enum SortSeriesType { + Name = 'name', + Max = 'max', + Min = 'min', + Sum = 'sum', + Avg = 'avg', +} + +export type SortSeriesData = { + sort_series_type: SortSeriesType; + sort_series_ascending: boolean; +}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts index e0b41f9f68..17629c0996 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts @@ -16,7 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { sections } from '@superset-ui/chart-controls'; +import { + DEFAULT_SORT_SERIES_DATA, + sections, +} from '@superset-ui/chart-controls'; import { t } from '@superset-ui/core'; import { OrientationType, @@ -25,7 +28,6 @@ import { } from './types'; import { DEFAULT_LEGEND_FORM_DATA, - DEFAULT_SORT_SERIES_DATA, DEFAULT_TITLE_FORM_DATA, } from '../constants'; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index d00f74a0f2..5565fee6a2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -146,6 +146,8 @@ export default function transformProps( truncateYAxis, xAxis: xAxisOrig, xAxisLabelRotation, + xAxisSortSeries, + xAxisSortSeriesAscending, xAxisTimeFormat, xAxisTitle, xAxisTitleMargin, @@ -200,6 +202,10 @@ export default function transformProps( isHorizontal, sortSeriesType, sortSeriesAscending, + xAxisSortSeries: groupby.length ? xAxisSortSeries : undefined, + xAxisSortSeriesAscending: groupby.length + ? xAxisSortSeriesAscending + : undefined, }); const showValueIndexes = extractShowValueIndexes(rawSeries, { stack, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts index bfc6c98fa5..b0b87bd188 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts @@ -24,8 +24,6 @@ import { LegendFormData, LegendOrientation, LegendType, - SortSeriesData, - SortSeriesType, TitleFormData, } from './types'; @@ -122,8 +120,3 @@ export const TOOLTIP_POINTER_MARGIN = 10; // If no satisfactory position can be found, how far away // from the edge of the window should the tooltip be kept export const TOOLTIP_OVERFLOW_MARGIN = 5; - -export const DEFAULT_SORT_SERIES_DATA: SortSeriesData = { - sort_series_type: SortSeriesType.Sum, - sort_series_ascending: false, -}; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx index 26f10e0fe4..bfd2634ac9 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/controls.tsx @@ -22,15 +22,12 @@ import { ControlPanelsContainerProps, ControlSetItem, ControlSetRow, + DEFAULT_SORT_SERIES_DATA, + SORT_SERIES_CHOICES, sharedControls, } from '@superset-ui/chart-controls'; -import { - DEFAULT_LEGEND_FORM_DATA, - DEFAULT_SORT_SERIES_DATA, - StackControlOptions, -} from './constants'; +import { DEFAULT_LEGEND_FORM_DATA, StackControlOptions } from './constants'; import { DEFAULT_FORM_DATA } from './Timeseries/constants'; -import { SortSeriesType } from './types'; const { legendMargin, legendOrientation, legendType, showLegend } = DEFAULT_LEGEND_FORM_DATA; @@ -225,13 +222,7 @@ const sortSeriesType: ControlSetItem = { type: 'SelectControl', freeForm: false, label: t('Sort Series By'), - choices: [ - [SortSeriesType.Name, t('Category name')], - [SortSeriesType.Sum, t('Total value')], - [SortSeriesType.Min, t('Minimum value')], - [SortSeriesType.Max, t('Maximum value')], - [SortSeriesType.Avg, t('Average value')], - ], + choices: SORT_SERIES_CHOICES, default: DEFAULT_SORT_SERIES_DATA.sort_series_type, renderTrigger: true, description: t( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts index cb44f17ed3..142c41c17d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts @@ -167,17 +167,4 @@ export interface TreePathInfo { value: number | number[]; } -export enum SortSeriesType { - Name = 'name', - Max = 'max', - Min = 'min', - Sum = 'sum', - Avg = 'avg', -} - -export type SortSeriesData = { - sort_series_type: SortSeriesType; - sort_series_ascending: boolean; -}; - export * from './Timeseries/types'; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index 2cfd7e831d..ea1691d11c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -18,6 +18,7 @@ * under the License. */ import { + AxisType, ChartDataResponseResult, DataRecord, DataRecordValue, @@ -27,22 +28,17 @@ import { NumberFormats, NumberFormatter, TimeFormatter, - AxisType, SupersetTheme, } from '@superset-ui/core'; +import { SortSeriesType } from '@superset-ui/chart-controls'; import { format, LegendComponentOption, SeriesOption } from 'echarts'; -import { sumBy, meanBy, minBy, maxBy, orderBy } from 'lodash'; +import { maxBy, meanBy, minBy, orderBy, sumBy } from 'lodash'; import { - StackControlsValue, NULL_STRING, + StackControlsValue, TIMESERIES_CONSTANTS, } from '../constants'; -import { - LegendOrientation, - LegendType, - SortSeriesType, - StackType, -} from '../types'; +import { LegendOrientation, LegendType, StackType } from '../types'; import { defaultLegendPadding } from '../defaults'; function isDefined(value: T | undefined | null): boolean { @@ -155,6 +151,84 @@ export function sortAndFilterSeries( ).map(({ name }) => name); } +export function sortRows( + rows: DataRecord[], + xAxis: string, + xAxisSortSeries: SortSeriesType, + xAxisSortSeriesAscending: boolean, +) { + const sortedRows = rows.map(row => { + let sortKey: DataRecordValue = ''; + let aggregate: number | undefined; + let entries = 0; + Object.entries(row).forEach(([key, value]) => { + const isValueDefined = isDefined(value); + if (key === xAxis) { + sortKey = value; + } + if ( + xAxisSortSeries === SortSeriesType.Name || + typeof value !== 'number' + ) { + return; + } + + if (!(xAxisSortSeries === SortSeriesType.Avg && !isValueDefined)) { + entries += 1; + } + + switch (xAxisSortSeries) { + case SortSeriesType.Avg: + case SortSeriesType.Sum: + if (aggregate === undefined) { + aggregate = value; + } else { + aggregate += value; + } + break; + case SortSeriesType.Min: + aggregate = + aggregate === undefined || (isValueDefined && value < aggregate) + ? value + : aggregate; + break; + case SortSeriesType.Max: + aggregate = + aggregate === undefined || (isValueDefined && value > aggregate) + ? value + : aggregate; + break; + default: + break; + } + }); + if ( + xAxisSortSeries === SortSeriesType.Avg && + entries > 0 && + aggregate !== undefined + ) { + aggregate /= entries; + } + + const value = + xAxisSortSeries === SortSeriesType.Name && typeof sortKey === 'string' + ? sortKey.toLowerCase() + : aggregate; + + return { + key: sortKey, + value, + row, + }; + }); + + return orderBy( + sortedRows, + ['value'], + [xAxisSortSeriesAscending ? 'asc' : 'desc'], + ).map(({ row }) => row); +} + export function extractSeries( data: DataRecord[], opts: { @@ -167,6 +241,8 @@ export function extractSeries( isHorizontal?: boolean; sortSeriesType?: SortSeriesType; sortSeriesAscending?: boolean; + xAxisSortSeries?: SortSeriesType; + xAxisSortSeriesAscending?: boolean; } = {}, ): SeriesOption[] { const { @@ -179,24 +255,30 @@ export function extractSeries( isHorizontal = false, sortSeriesType, sortSeriesAscending, + xAxisSortSeries, + xAxisSortSeriesAscending, } = opts; if (data.length === 0) return []; const rows: DataRecord[] = data.map(datum => ({ ...datum, [xAxis]: datum[xAxis], })); - const series = sortAndFilterSeries( + const sortedSeries = sortAndFilterSeries( rows, xAxis, extraMetricLabels, sortSeriesType, sortSeriesAscending, ); + const sortedRows = + isDefined(xAxisSortSeries) && isDefined(xAxisSortSeriesAscending) + ? sortRows(rows, xAxis, xAxisSortSeries!, xAxisSortSeriesAscending!) + : rows; - return series.map(name => ({ + return sortedSeries.map(name => ({ id: name, name, - data: rows + data: sortedRows .map((row, idx) => { const isNextToDefinedValue = isDefined(rows[idx - 1]?.[name]) || isDefined(rows[idx + 1]?.[name]); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts index 4daeac6a86..69570ff007 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { SortSeriesType } from '@superset-ui/chart-controls'; import { DataRecord, getNumberFormatter, @@ -33,8 +34,9 @@ import { getOverMaxHiddenFormatter, sanitizeHtml, sortAndFilterSeries, + sortRows, } from '../../src/utils/series'; -import { LegendOrientation, LegendType, SortSeriesType } from '../../src/types'; +import { LegendOrientation, LegendType } from '../../src/types'; import { defaultLegendPadding } from '../../src/defaults'; import { NULL_STRING } from '../../src/constants'; @@ -48,42 +50,149 @@ const expectedThemeProps = { }, }; -test('sortAndFilterSeries', () => { - const data: DataRecord[] = [ +const sortData: DataRecord[] = [ + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + { my_x_axis: null, x: 4, y: 3, z: 7 }, +]; + +test('sortRows by name ascending', () => { + expect(sortRows(sortData, 'my_x_axis', SortSeriesType.Name, true)).toEqual([ { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, { my_x_axis: 'foo', x: null, y: 10, z: 5 }, { my_x_axis: null, x: 4, y: 3, z: 7 }, - ]; + ]); +}); +test('sortRows by name descending', () => { + expect(sortRows(sortData, 'my_x_axis', SortSeriesType.Name, false)).toEqual([ + { my_x_axis: null, x: 4, y: 3, z: 7 }, + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + ]); +}); + +test('sortRows by sum ascending', () => { + expect(sortRows(sortData, 'my_x_axis', SortSeriesType.Sum, true)).toEqual([ + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + { my_x_axis: null, x: 4, y: 3, z: 7 }, + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + ]); +}); + +test('sortRows by sum descending', () => { + expect(sortRows(sortData, 'my_x_axis', SortSeriesType.Sum, false)).toEqual([ + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + { my_x_axis: null, x: 4, y: 3, z: 7 }, + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + ]); +}); + +test('sortRows by avg ascending', () => { + expect(sortRows(sortData, 'my_x_axis', SortSeriesType.Avg, true)).toEqual([ + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + { my_x_axis: null, x: 4, y: 3, z: 7 }, + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + ]); +}); + +test('sortRows by avg descending', () => { + expect(sortRows(sortData, 'my_x_axis', SortSeriesType.Avg, false)).toEqual([ + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + { my_x_axis: null, x: 4, y: 3, z: 7 }, + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + ]); +}); + +test('sortRows by min ascending', () => { + expect(sortRows(sortData, 'my_x_axis', SortSeriesType.Min, true)).toEqual([ + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + { my_x_axis: null, x: 4, y: 3, z: 7 }, + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + ]); +}); + +test('sortRows by min descending', () => { + expect(sortRows(sortData, 'my_x_axis', SortSeriesType.Min, false)).toEqual([ + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + { my_x_axis: null, x: 4, y: 3, z: 7 }, + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + ]); +}); + +test('sortRows by max ascending', () => { + expect(sortRows(sortData, 'my_x_axis', SortSeriesType.Min, true)).toEqual([ + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + { my_x_axis: null, x: 4, y: 3, z: 7 }, + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + ]); +}); + +test('sortRows by max descending', () => { + expect(sortRows(sortData, 'my_x_axis', SortSeriesType.Min, false)).toEqual([ + { my_x_axis: 'foo', x: null, y: 10, z: 5 }, + { my_x_axis: null, x: 4, y: 3, z: 7 }, + { my_x_axis: 'abc', x: 1, y: 0, z: 2 }, + ]); +}); + +test('sortAndFilterSeries by min ascending', () => { expect( - sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Min, true), + sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Min, true), ).toEqual(['y', 'x', 'z']); +}); + +test('sortAndFilterSeries by min descending', () => { expect( - sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Min, false), + sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Min, false), ).toEqual(['z', 'x', 'y']); +}); + +test('sortAndFilterSeries by max ascending', () => { expect( - sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Max, true), + sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Max, true), ).toEqual(['x', 'z', 'y']); +}); + +test('sortAndFilterSeries by max descending', () => { expect( - sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Max, false), + sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Max, false), ).toEqual(['y', 'z', 'x']); +}); + +test('sortAndFilterSeries by avg ascending', () => { expect( - sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Avg, true), + sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Avg, true), ).toEqual(['x', 'y', 'z']); +}); + +test('sortAndFilterSeries by avg descending', () => { expect( - sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Avg, false), + sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Avg, false), ).toEqual(['z', 'y', 'x']); +}); + +test('sortAndFilterSeries by sum ascending', () => { expect( - sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Sum, true), + sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Sum, true), ).toEqual(['x', 'y', 'z']); +}); + +test('sortAndFilterSeries by sum descending', () => { expect( - sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Sum, false), + sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Sum, false), ).toEqual(['z', 'y', 'x']); +}); + +test('sortAndFilterSeries by name ascending', () => { expect( - sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Name, true), + sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Name, true), ).toEqual(['x', 'y', 'z']); +}); + +test('sortAndFilterSeries by name descending', () => { expect( - sortAndFilterSeries(data, 'my_x_axis', [], SortSeriesType.Name, false), + sortAndFilterSeries(sortData, 'my_x_axis', [], SortSeriesType.Name, false), ).toEqual(['z', 'y', 'x']); });