diff --git a/docs/sources/panels/legend-options.md b/docs/sources/panels/legend-options.md index 9e8822f39ea..69a42224da6 100644 --- a/docs/sources/panels/legend-options.md +++ b/docs/sources/panels/legend-options.md @@ -10,8 +10,13 @@ Use the legend to adjust how a visualization displays series. This legend functi This topic currently applies to the following visualizations: +- [Bar chart panel]({{< relref "../visualizations/bar-chart.md">}}) +- [Histogram panel]({{< relref "../visualizations/histogram.md">}}) - [Pie chart panel]({{< relref "../visualizations/pie-chart-panel.md">}}) +- [State timeline panel]({{< relref "../visualizations/state-timeline.md">}}) +- [Status history panel]({{< relref "../visualizations/status-history.md">}}) - [Time series panel]({{< relref "../visualizations/time-series/_index.md" >}}) +- XY chart panel ## Toggle series @@ -34,3 +39,12 @@ This creates a system override that hides the other series. You can view this ov Click on the series icon (colored line beside the series label) in the legend to change selected series color. ![Change legend series color](/static/img/docs/legend/legend-series-color-7-5.png) + +## Sort series + +Change legend mode to **Table** and choose [calculations]({{< relref "./calculations-list.md" >}}) to be displayed in the legend. Click the calculation name header in the legend table to sort the values in the table in ascending or descending order. +The sort order affects the positions of the bars in the Bar chart panel as well as the order of stacked series in the Time series and Bar chart panels. + +> **Note:** This feature is only supported in these panels: Bar chart, Histogram, Time series, XY Chart. + +![Sort legend series](/static/img/docs/legend/legend-series-sort-8-3.png) diff --git a/packages/grafana-schema/src/schema/graph.gen.ts b/packages/grafana-schema/src/schema/graph.gen.ts index 5d9004906c0..0c2df46280c 100644 --- a/packages/grafana-schema/src/schema/graph.gen.ts +++ b/packages/grafana-schema/src/schema/graph.gen.ts @@ -258,6 +258,8 @@ export interface VizLegendOptions { displayMode: LegendDisplayMode; isVisible?: boolean; placement: LegendPlacement; + sortBy?: string; + sortDesc?: boolean; } export enum BarGaugeDisplayMode { diff --git a/packages/grafana-schema/src/schema/legend.cue b/packages/grafana-schema/src/schema/legend.cue index 23986bd83ff..336ab2324b2 100644 --- a/packages/grafana-schema/src/schema/legend.cue +++ b/packages/grafana-schema/src/schema/legend.cue @@ -5,9 +5,11 @@ LegendPlacement: "bottom" | "right" @cuetsy(kind="type") LegendDisplayMode: "list" | "table" | "hidden" @cuetsy(kind="enum") VizLegendOptions: { - displayMode: LegendDisplayMode - placement: LegendPlacement + displayMode: LegendDisplayMode + placement: LegendPlacement asTable?: bool isVisible?: bool - calcs: [...string] + sortBy?: string + sortDesc?: bool + calcs: [...string] } @cuetsy(kind="interface") diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts index 583552046ea..b1581a2414f 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts +++ b/packages/grafana-ui/src/components/PanelChrome/PanelContext.ts @@ -59,6 +59,11 @@ export interface PanelContext { /** Update instance state, this is only supported in dashboard panel context currently */ onInstanceStateChange?: (state: any) => void; + + /** + * Called when a panel is changing the sort order of the legends. + */ + onToggleLegendSort?: (sortBy: string) => void; } export const PanelContextRoot = React.createContext({ diff --git a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx b/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx index 67a2671df9b..f9da81aebdd 100644 --- a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx +++ b/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx @@ -8,7 +8,7 @@ import { preparePlotConfigBuilder } from './utils'; import { withTheme2 } from '../../themes/ThemeContext'; import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext'; -const propsToDiff: string[] = []; +const propsToDiff: string[] = ['legend']; type TimeSeriesProps = Omit; @@ -18,7 +18,7 @@ export class UnthemedTimeSeries extends React.Component { prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { const { eventBus, sync } = this.context; - const { theme, timeZone } = this.props; + const { theme, timeZone, legend } = this.props; return preparePlotConfigBuilder({ frame: alignedFrame, @@ -28,6 +28,7 @@ export class UnthemedTimeSeries extends React.Component { eventBus, sync, allFrames, + legend, }); }; diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.ts b/packages/grafana-ui/src/components/TimeSeries/utils.ts index af1e092e96d..00b9a2356f7 100644 --- a/packages/grafana-ui/src/components/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/components/TimeSeries/utils.ts @@ -23,8 +23,9 @@ import { VisibilityMode, ScaleDirection, ScaleOrientation, + VizLegendOptions, } from '@grafana/schema'; -import { collectStackingGroups, preparePlotData } from '../uPlot/utils'; +import { collectStackingGroups, orderIdsByCalcs, preparePlotData } from '../uPlot/utils'; import uPlot from 'uplot'; const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); @@ -35,7 +36,7 @@ const defaultConfig: GraphFieldConfig = { axisPlacement: AxisPlacement.Auto, }; -export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursorSync }> = ({ +export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursorSync; legend?: VizLegendOptions }> = ({ frame, theme, timeZone, @@ -43,10 +44,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor eventBus, sync, allFrames, + legend, }) => { const builder = new UPlotConfigBuilder(timeZone); - builder.setPrepData(preparePlotData); + builder.setPrepData((prepData) => preparePlotData(prepData, undefined, legend)); // X is the first field in the aligned frame const xField = frame.fields[0]; @@ -265,7 +267,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor if (stackingGroups.size !== 0) { builder.setStacking(true); - for (const [_, seriesIdxs] of stackingGroups.entries()) { + for (const [_, seriesIds] of stackingGroups.entries()) { + const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame }); for (let j = seriesIdxs.length - 1; j > 0; j--) { builder.addBand({ series: [seriesIdxs[j], seriesIdxs[j - 1]], diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx index e034f85f37d..c44a72924d2 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegend.tsx @@ -23,7 +23,7 @@ export function VizLegend({ itemRenderer, readonly, }: LegendProps) { - const { eventBus, onToggleSeriesVisibility } = usePanelContext(); + const { eventBus, onToggleSeriesVisibility, onToggleLegendSort } = usePanelContext(); const onMouseEnter = useCallback( (item: VizLegendItem, event: React.MouseEvent) => { @@ -82,7 +82,7 @@ export function VizLegend({ sortBy={sortKey} sortDesc={sortDesc} onLabelClick={onLegendLabelClick} - onToggleSort={onToggleSort} + onToggleSort={onToggleSort || onToggleLegendSort} onLabelMouseEnter={onMouseEnter} onLabelMouseOut={onMouseOut} itemRenderer={itemRenderer} diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index dbe7fba1103..0bd289bd8f9 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -3,7 +3,7 @@ import { css, cx } from '@emotion/css'; import { VizLegendTableProps } from './types'; import { Icon } from '../Icon/Icon'; import { useStyles2 } from '../../themes/ThemeContext'; -import { sortBy } from 'lodash'; +import { orderBy } from 'lodash'; import { LegendTableItem } from './VizLegendTableItem'; import { DisplayValue, GrafanaTheme2 } from '@grafana/data'; @@ -34,13 +34,17 @@ export const VizLegendTable = ({ } const sortedItems = sortKey - ? sortBy(items, (item) => { - if (item.getDisplayValues) { - const stat = item.getDisplayValues().filter((stat) => stat.title === sortKey)[0]; - return stat && stat.numeric; - } - return undefined; - }) + ? orderBy( + items, + (item) => { + if (item.getDisplayValues) { + const stat = item.getDisplayValues().filter((stat) => stat.title === sortKey)[0]; + return stat && stat.numeric; + } + return undefined; + }, + sortDesc ? 'desc' : 'asc' + ) : items; if (!itemRenderer) { @@ -68,7 +72,9 @@ export const VizLegendTable = ({ { if (onToggleSort) { onToggleSort(columnTitle); @@ -76,9 +82,7 @@ export const VizLegendTable = ({ }} > {columnTitle} - {sortKey === columnTitle && ( - - )} + {sortKey === columnTitle && } ); })} @@ -94,21 +98,23 @@ const getStyles = (theme: GrafanaTheme2) => ({ width: 100%; th:first-child { width: 100%; + border-bottom: 1px solid ${theme.colors.border.weak}; } `, header: css` color: ${theme.colors.primary.text}; font-weight: ${theme.typography.fontWeightMedium}; border-bottom: 1px solid ${theme.colors.border.weak}; - padding: ${theme.spacing(0.25, 1)}; + padding: ${theme.spacing(0.25, 2, 0.25, 1)}; font-size: ${theme.typography.bodySmall.fontSize}; - text-align: right; + text-align: left; white-space: nowrap; `, + // This needs to be padding-right - icon size(xs==12) to avoid jumping + withIcon: css` + padding-right: 4px; + `, headerSortable: css` cursor: pointer; `, - sortIcon: css` - margin-left: ${theme.spacing(1)}; - `, }); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 720396ceee5..f35f7bbb712 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -120,7 +120,7 @@ const getStyles = (theme: GrafanaTheme2) => { align-items: center; `, value: css` - text-align: right; + text-align: left; `, yAxisLabel: css` color: ${theme.colors.text.secondary}; diff --git a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx index 847a268870f..ae7117765b6 100644 --- a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx +++ b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx @@ -85,7 +85,13 @@ export const PlotLegend: React.FC = ({ return ( - + ); }; diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index 68f9ffecd8f..3897377bba5 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -1,4 +1,4 @@ -import { preparePlotData, timeFormatToTemplate } from './utils'; +import { orderIdsByCalcs, preparePlotData, timeFormatToTemplate } from './utils'; import { FieldType, MutableDataFrame } from '@grafana/data'; import { StackingMode } from '@grafana/schema'; @@ -295,5 +295,113 @@ describe('preparePlotData', () => { ] `); }); + + describe('with legend sorted', () => { + it('should affect when single group', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, + { + name: 'a', + values: [-10, 20, 10], + state: { calcs: { max: 20 } }, + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, + }, + { + name: 'b', + values: [10, 10, 10], + state: { calcs: { max: 10 } }, + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, + }, + { + name: 'c', + values: [20, 20, 20], + state: { calcs: { max: 20 } }, + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, + }, + ], + }); + + expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: false } as any)).toMatchInlineSnapshot(` + Array [ + Array [ + 9997, + 9998, + 9999, + ], + Array [ + 0, + 30, + 20, + ], + Array [ + 10, + 10, + 10, + ], + Array [ + 20, + 50, + 40, + ], + ] + `); + expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: true } as any)).toMatchInlineSnapshot(` + Array [ + Array [ + 9997, + 9998, + 9999, + ], + Array [ + -10, + 20, + 10, + ], + Array [ + 20, + 50, + 40, + ], + Array [ + 10, + 40, + 30, + ], + ] + `); + }); + }); + }); +}); + +describe('orderIdsByCalcs', () => { + const ids = [1, 2, 3, 4]; + const frame = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, + { name: 'a', values: [-10, 20, 10], state: { calcs: { min: -10 } } }, + { name: 'b', values: [20, 20, 20], state: { calcs: { min: 20 } } }, + { name: 'c', values: [10, 10, 10], state: { calcs: { min: 10 } } }, + { name: 'd', values: [30, 30, 30] }, + ], + }); + + it.each([ + { legend: undefined }, + { legend: { sortBy: 'Min' } }, + { legend: { sortDesc: false } }, + { legend: {} }, + { sortBy: 'Mik', sortDesc: true }, + ])('should return without ordering if legend option is %o', (legend: any) => { + const result = orderIdsByCalcs({ ids, frame, legend }); + expect(result).toEqual([1, 2, 3, 4]); + }); + + it('should order the ids based on the frame stat', () => { + const resultDesc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: true } as any }); + expect(resultDesc).toEqual([4, 2, 3, 1]); + const resultAsc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: false } as any }); + expect(resultAsc).toEqual([1, 3, 2, 4]); }); }); diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index aaa496f0654..58f8ae37517 100755 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -1,8 +1,9 @@ import { DataFrame, ensureTimeField, Field, FieldType } from '@grafana/data'; -import { StackingMode } from '@grafana/schema'; -import { createLogger } from '../../utils/logger'; -import { attachDebugger } from '../../utils'; +import { StackingMode, VizLegendOptions } from '@grafana/schema'; +import { orderBy } from 'lodash'; import { AlignedData, Options, PaddingSide } from 'uplot'; +import { attachDebugger } from '../../utils'; +import { createLogger } from '../../utils/logger'; const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g; @@ -39,7 +40,11 @@ interface StackMeta { } /** @internal */ -export function preparePlotData(frames: DataFrame[], onStackMeta?: (meta: StackMeta) => void): AlignedData { +export function preparePlotData( + frames: DataFrame[], + onStackMeta?: (meta: StackMeta) => void, + legend?: VizLegendOptions +): AlignedData { const frame = frames[0]; const result: any[] = []; const stackingGroups: Map = new Map(); @@ -67,7 +72,9 @@ export function preparePlotData(frames: DataFrame[], onStackMeta?: (meta: StackM alignedTotals[0] = null; // array or stacking groups - for (const [_, seriesIdxs] of stackingGroups.entries()) { + for (const [_, seriesIds] of stackingGroups.entries()) { + const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame }); + const groupTotals = byPct ? Array(dataLength).fill(0) : null; if (byPct) { @@ -184,3 +191,23 @@ export const pluginLogger = createLogger('uPlot'); export const pluginLog = pluginLogger.logger; // pluginLogger.enable(); attachDebugger('graphng', undefined, pluginLogger); + +type OrderIdsByCalcsOptions = { + legend?: VizLegendOptions; + ids: number[]; + frame: DataFrame; +}; +export function orderIdsByCalcs({ legend, ids, frame }: OrderIdsByCalcsOptions) { + if (!legend?.sortBy || legend.sortDesc == null) { + return ids; + } + const orderedIds = orderBy( + ids, + (id) => { + return frame.fields[id].state?.calcs?.[legend.sortBy!.toLowerCase()]; + }, + legend.sortDesc ? 'desc' : 'asc' + ); + + return orderedIds; +} diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 7f9995eef85..cc91d44babd 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -20,6 +20,7 @@ import { toUtc, } from '@grafana/data'; import { ErrorBoundary, PanelContext, PanelContextProvider, SeriesVisibilityChangeMode } from '@grafana/ui'; +import { VizLegendOptions } from '@grafana/schema'; import { selectors } from '@grafana/e2e-selectors'; import { PanelHeader } from './PanelHeader/PanelHeader'; @@ -89,6 +90,7 @@ export class PanelChrome extends PureComponent { onAnnotationDelete: this.onAnnotationDelete, canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable), onInstanceStateChange: this.onInstanceStateChange, + onToggleLegendSort: this.onToggleLegendSort, }, data: this.getInitialPanelDataState(), }; @@ -127,6 +129,35 @@ export class PanelChrome extends PureComponent { ); }; + onToggleLegendSort = (sortKey: string) => { + const legendOptions: VizLegendOptions = this.props.panel.options.legend; + + // We don't want to do anything when legend options are not available + if (!legendOptions) { + return; + } + + let sortDesc = legendOptions.sortDesc; + let sortBy = legendOptions.sortBy; + if (sortKey !== sortBy) { + sortDesc = undefined; + } + + // if already sort ascending, disable sorting + if (sortDesc === false) { + sortBy = undefined; + sortDesc = undefined; + } else { + sortDesc = !sortDesc; + sortBy = sortKey; + } + + this.onOptionsChange({ + ...this.props.panel.options, + legend: { ...legendOptions, sortBy, sortDesc }, + }); + }; + getInitialPanelDataState(): PanelData { return { state: LoadingState.NotStarted, diff --git a/public/app/plugins/panel/barchart/BarChart.tsx b/public/app/plugins/panel/barchart/BarChart.tsx index bc7fcafaefa..ee416b7b7e6 100644 --- a/public/app/plugins/panel/barchart/BarChart.tsx +++ b/public/app/plugins/panel/barchart/BarChart.tsx @@ -4,7 +4,7 @@ import { DataFrame, FieldType, TimeRange } from '@grafana/data'; import { GraphNG, GraphNGProps, PlotLegend, UPlotConfigBuilder, usePanelContext, useTheme2 } from '@grafana/ui'; import { LegendDisplayMode } from '@grafana/schema'; import { BarChartOptions } from './types'; -import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; +import { isLegendOrdered, preparePlotConfigBuilder, preparePlotFrame } from './utils'; import { PropDiffFn } from '../../../../../packages/grafana-ui/src/components/GraphNG/GraphNG'; /** @@ -20,6 +20,7 @@ const propsToDiff: Array = [ 'groupWidth', 'stacking', 'showValue', + 'legend', (prev: BarChartProps, next: BarChartProps) => next.text?.valueSize === prev.text?.valueSize, ]; @@ -39,6 +40,11 @@ export const BarChart: React.FC = (props) => { }; const rawValue = (seriesIdx: number, valueIdx: number) => { + // When sorted by legend state.seriesIndex is not changed and is not equal to the sorted index of the field + if (isLegendOrdered(props.legend)) { + return frame0Ref.current!.fields[seriesIdx].values.get(valueIdx); + } + let field = frame0Ref.current!.fields.find( (f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIdx - 1 ); diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index f766626c92d..ec3a8a5b24a 100755 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -14,11 +14,7 @@ interface Props extends PanelProps {} export const BarChartPanel: React.FunctionComponent = ({ data, options, width, height, timeZone }) => { const theme = useTheme2(); - const { frames, warn } = useMemo(() => prepareGraphableFrames(data?.series, theme, options.stacking), [ - data, - theme, - options.stacking, - ]); + const { frames, warn } = useMemo(() => prepareGraphableFrames(data?.series, theme, options), [data, theme, options]); const orientation = useMemo(() => { if (!options.orientation || options.orientation === VizOrientation.Auto) { return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical; diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index 51c7c907f68..9b4fdfdc319 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.ts @@ -3,7 +3,14 @@ import { pointWithin, Quadtree, Rect } from './quadtree'; import { distribute, SPACE_BETWEEN } from './distribute'; import { DataFrame, GrafanaTheme2 } from '@grafana/data'; import { calculateFontSize, PlotTooltipInterpolator } from '@grafana/ui'; -import { StackingMode, VisibilityMode, ScaleDirection, ScaleOrientation, VizTextDisplayOptions } from '@grafana/schema'; +import { + StackingMode, + VisibilityMode, + ScaleDirection, + ScaleOrientation, + VizTextDisplayOptions, + VizLegendOptions, +} from '@grafana/schema'; import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils'; const groupDistr = SPACE_BETWEEN; @@ -40,6 +47,7 @@ export interface BarsOptions { text?: VizTextDisplayOptions; onHover?: (seriesIdx: number, valueIdx: number) => void; onLeave?: (seriesIdx: number, valueIdx: number) => void; + legend?: VizLegendOptions; } /** @@ -311,9 +319,13 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { function prepData(frames: DataFrame[]) { alignedTotals = null; - return preparePlotData(frames, ({ totals }) => { - alignedTotals = totals; - }); + return preparePlotData( + frames, + ({ totals }) => { + alignedTotals = totals; + }, + opts.legend + ); } return { diff --git a/public/app/plugins/panel/barchart/utils.test.ts b/public/app/plugins/panel/barchart/utils.test.ts index 0b1333f4f22..61fcd36a497 100644 --- a/public/app/plugins/panel/barchart/utils.test.ts +++ b/public/app/plugins/panel/barchart/utils.test.ts @@ -144,7 +144,7 @@ describe('BarChart utils', () => { describe('prepareGraphableFrames', () => { it('will warn when there is no data in the response', () => { - const result = prepareGraphableFrames([], createTheme(), StackingMode.None); + const result = prepareGraphableFrames([], createTheme(), { stacking: StackingMode.None } as any); expect(result.warn).toEqual('No data in response'); }); @@ -155,7 +155,7 @@ describe('BarChart utils', () => { { name: 'value', values: [1, 2, 3, 4, 5] }, ], }); - const result = prepareGraphableFrames([df], createTheme(), StackingMode.None); + const result = prepareGraphableFrames([df], createTheme(), { stacking: StackingMode.None } as any); expect(result.warn).toEqual('Bar charts requires a string field'); expect(result.frames).toBeUndefined(); }); @@ -167,7 +167,7 @@ describe('BarChart utils', () => { { name: 'value', type: FieldType.boolean, values: [true, true, true, true, true] }, ], }); - const result = prepareGraphableFrames([df], createTheme(), StackingMode.None); + const result = prepareGraphableFrames([df], createTheme(), { stacking: StackingMode.None } as any); expect(result.warn).toEqual('No numeric fields found'); expect(result.frames).toBeUndefined(); }); @@ -179,7 +179,7 @@ describe('BarChart utils', () => { { name: 'value', values: [-10, NaN, 10, -Infinity, +Infinity] }, ], }); - const result = prepareGraphableFrames([df], createTheme(), StackingMode.None); + const result = prepareGraphableFrames([df], createTheme(), { stacking: StackingMode.None } as any); const field = result.frames![0].fields[1]; expect(field!.values.toArray()).toMatchInlineSnapshot(` @@ -192,5 +192,32 @@ describe('BarChart utils', () => { ] `); }); + + it('should sort fields when legend sortBy and sortDesc are set', () => { + const frame = new MutableDataFrame({ + fields: [ + { name: 'string', type: FieldType.string, values: ['a', 'b', 'c'] }, + { name: 'a', values: [-10, 20, 10], state: { calcs: { min: -10 } } }, + { name: 'b', values: [20, 20, 20], state: { calcs: { min: 20 } } }, + { name: 'c', values: [10, 10, 10], state: { calcs: { min: 10 } } }, + ], + }); + + const resultAsc = prepareGraphableFrames([frame], createTheme(), { + legend: { sortBy: 'Min', sortDesc: false }, + } as any); + expect(resultAsc.frames![0].fields[0].type).toBe(FieldType.string); + expect(resultAsc.frames![0].fields[1].name).toBe('a'); + expect(resultAsc.frames![0].fields[2].name).toBe('c'); + expect(resultAsc.frames![0].fields[3].name).toBe('b'); + + const resultDesc = prepareGraphableFrames([frame], createTheme(), { + legend: { sortBy: 'Min', sortDesc: true }, + } as any); + expect(resultDesc.frames![0].fields[0].type).toBe(FieldType.string); + expect(resultDesc.frames![0].fields[1].name).toBe('b'); + expect(resultDesc.frames![0].fields[2].name).toBe('c'); + expect(resultDesc.frames![0].fields[3].name).toBe('a'); + }); }); }); diff --git a/public/app/plugins/panel/barchart/utils.ts b/public/app/plugins/panel/barchart/utils.ts index 9254d0c2018..97610b12eb6 100644 --- a/public/app/plugins/panel/barchart/utils.ts +++ b/public/app/plugins/panel/barchart/utils.ts @@ -13,9 +13,17 @@ import { } from '@grafana/data'; import { BarChartFieldConfig, BarChartOptions, defaultBarChartFieldConfig } from './types'; import { BarsOptions, getConfig } from './bars'; -import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation, StackingMode } from '@grafana/schema'; +import { + AxisPlacement, + ScaleDirection, + ScaleDistribution, + ScaleOrientation, + StackingMode, + VizLegendOptions, +} from '@grafana/schema'; import { FIXED_UNIT, UPlotConfigBuilder, UPlotConfigPrepFn } from '@grafana/ui'; -import { collectStackingGroups } from '../../../../../packages/grafana-ui/src/components/uPlot/utils'; +import { collectStackingGroups, orderIdsByCalcs } from '../../../../../packages/grafana-ui/src/components/uPlot/utils'; +import { orderBy } from 'lodash'; /** @alpha */ function getBarCharScaleOrientation(orientation: VizOrientation) { @@ -47,6 +55,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ text, rawValue, allFrames, + legend, }) => { const builder = new UPlotConfigBuilder(); const defaultValueFormatter = (seriesIdx: number, value: any) => @@ -73,6 +82,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ formatValue, text, showValue, + legend, }; const config = getConfig(opts, theme); @@ -108,14 +118,14 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ }); let seriesIndex = 0; - + const legendOrdered = isLegendOrdered(legend); const stackingGroups: Map = new Map(); // iterate the y values for (let i = 1; i < frame.fields.length; i++) { const field = frame.fields[i]; - field.state!.seriesIndex = seriesIndex++; + seriesIndex++; const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom }; @@ -144,9 +154,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ // The following properties are not used in the uPlot config, but are utilized as transport for legend config // PlotLegend currently gets unfiltered DataFrame[], so index must be into that field array, not the prepped frame's which we're iterating here dataFrameFieldIndex: { - fieldIndex: allFrames[0].fields.findIndex( - (f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIndex - 1 - ), + fieldIndex: legendOrdered + ? i + : allFrames[0].fields.findIndex( + (f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIndex - 1 + ), frameIndex: 0, }, }); @@ -192,7 +204,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ if (stackingGroups.size !== 0) { builder.setStacking(true); - for (const [_, seriesIdxs] of stackingGroups.entries()) { + for (const [_, seriesIds] of stackingGroups.entries()) { + const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame }); for (let j = seriesIdxs.length - 1; j > 0; j--) { builder.addBand({ series: [seriesIdxs[j], seriesIdxs[j - 1]], @@ -229,7 +242,7 @@ export function preparePlotFrame(data: DataFrame[]) { export function prepareGraphableFrames( series: DataFrame[], theme: GrafanaTheme2, - stacking: StackingMode + options: BarChartOptions ): { frames?: DataFrame[]; warn?: string } { if (!series?.length) { return { warn: 'No data in response' }; @@ -250,6 +263,7 @@ export function prepareGraphableFrames( }; } + const legendOrdered = isLegendOrdered(options.legend); let seriesIndex = 0; for (let frame of series) { @@ -268,7 +282,7 @@ export function prepareGraphableFrames( ...field.config.custom, stacking: { group: '_', - mode: stacking, + mode: options.stacking, }, }, }, @@ -282,7 +296,7 @@ export function prepareGraphableFrames( ), }; - if (stacking === StackingMode.Percent) { + if (options.stacking === StackingMode.Percent) { copy.config.unit = 'percentunit'; copy.display = getDisplayProcessor({ field: copy, theme }); } @@ -293,11 +307,29 @@ export function prepareGraphableFrames( } } + let orderedFields: Field[] | undefined; + + if (legendOrdered) { + orderedFields = orderBy( + fields, + ({ state }) => { + return state?.calcs?.[options.legend.sortBy!.toLowerCase()]; + }, + options.legend.sortDesc ? 'desc' : 'asc' + ); + // The string field needs to be the first one + if (orderedFields[orderedFields.length - 1].type === FieldType.string) { + orderedFields.unshift(orderedFields.pop()!); + } + } + frames.push({ ...frame, - fields, + fields: orderedFields || fields, }); } return { frames }; } + +export const isLegendOrdered = (options: VizLegendOptions) => Boolean(options?.sortBy && options.sortDesc !== null);