From 5654a5224991056087c1ee7e2af3916e74b74110 Mon Sep 17 00:00:00 2001 From: Ihor Yeromin Date: Wed, 21 Aug 2024 18:10:53 +0200 Subject: [PATCH] feat(barchart): render legend threshold colors --- .betterer.results | 7 + .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + .../src/components/VizLegend2/SeriesIcon.tsx | 84 ++++++++++ .../src/components/VizLegend2/VizLegend2.tsx | 139 ++++++++++++++++ .../components/VizLegend2/VizLegendList.tsx | 121 ++++++++++++++ .../VizLegend2/VizLegendListItem.tsx | 128 +++++++++++++++ .../VizLegend2/VizLegendSeriesIcon.tsx | 49 ++++++ .../VizLegend2/VizLegendStatsList.tsx | 46 ++++++ .../components/VizLegend2/VizLegendTable.tsx | 145 +++++++++++++++++ .../VizLegend2/VizLegendTableItem.tsx | 153 ++++++++++++++++++ .../src/components/VizLegend2/types.ts | 54 +++++++ .../src/components/VizLegend2/utils.ts | 8 + packages/grafana-ui/src/components/index.ts | 1 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 ++ .../plugins/panel/barchart/BarChartLegend.tsx | 66 ++++++-- 19 files changed, 1011 insertions(+), 17 deletions(-) create mode 100644 packages/grafana-ui/src/components/VizLegend2/SeriesIcon.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend2/VizLegend2.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend2/VizLegendList.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend2/VizLegendListItem.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend2/VizLegendSeriesIcon.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend2/VizLegendStatsList.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend2/VizLegendTable.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend2/VizLegendTableItem.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend2/types.ts create mode 100644 packages/grafana-ui/src/components/VizLegend2/utils.ts diff --git a/.betterer.results b/.betterer.results index c35064cb70b..014d128771d 100644 --- a/.betterer.results +++ b/.betterer.results @@ -913,6 +913,13 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "packages/grafana-ui/src/components/VizLegend2/VizLegendTableItem.tsx:5381": [ + [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] + ], + "packages/grafana-ui/src/components/VizLegend2/types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], "packages/grafana-ui/src/components/VizRepeater/VizRepeater.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index d76bb3845ec..427489ac205 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -195,6 +195,7 @@ Experimental features might be changed or removed without prior notice. | `backgroundPluginInstaller` | Enable background plugin installer | | `dataplaneAggregator` | Enable grafana dataplane aggregator | | `adhocFilterOneOf` | Exposes a new 'one of' operator for ad-hoc filters. This operator allows users to filter by multiple values in a single filter. | +| `newVizLegend` | Enhanced legend for visualizations | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 1c577afa5f0..b18d6fb5cb6 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -204,4 +204,5 @@ export interface FeatureToggles { backgroundPluginInstaller?: boolean; dataplaneAggregator?: boolean; adhocFilterOneOf?: boolean; + newVizLegend?: boolean; } diff --git a/packages/grafana-ui/src/components/VizLegend2/SeriesIcon.tsx b/packages/grafana-ui/src/components/VizLegend2/SeriesIcon.tsx new file mode 100644 index 00000000000..12f64d89520 --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend2/SeriesIcon.tsx @@ -0,0 +1,84 @@ +import { css, cx } from '@emotion/css'; +import { CSSProperties } from 'react'; +import * as React from 'react'; + +import { GrafanaTheme2, fieldColorModeRegistry } from '@grafana/data'; +import { LineStyle } from '@grafana/schema'; + +import { useTheme2, useStyles2 } from '../../themes'; + +export interface Props extends React.HTMLAttributes { + color?: string; + gradient?: string; + lineStyle?: LineStyle; +} + +export const SeriesIcon = React.memo( + React.forwardRef(({ color, className, gradient, lineStyle, ...restProps }, ref) => { + const theme = useTheme2(); + const styles = useStyles2(getStyles); + + let cssColor: string; + + if (gradient) { + const colors = fieldColorModeRegistry.get(gradient).getColors?.(theme); + if (colors?.length) { + cssColor = `linear-gradient(90deg, ${colors.join(', ')})`; + } else { + // Not sure what to default to, this will return gray, this should not happen though. + cssColor = theme.visualization.getColorByName(''); + } + } else { + cssColor = color!; + } + + let customStyle: CSSProperties; + + if (lineStyle?.fill === 'dot' && !gradient) { + // make a circle bg image and repeat it + customStyle = { + backgroundImage: `radial-gradient(circle at 2px 2px, ${color} 2px, transparent 0)`, + backgroundSize: '4px 4px', + backgroundRepeat: 'space', + }; + } else if (lineStyle?.fill === 'dash' && !gradient) { + // make a rectangle bg image and repeat it + customStyle = { + backgroundImage: `linear-gradient(to right, ${color} 100%, transparent 0%)`, + backgroundSize: '6px 4px', + backgroundRepeat: 'space', + }; + } else { + customStyle = { + background: cssColor, + borderRadius: theme.shape.radius.pill, + }; + } + + return ( +
+ ); + }) +); + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + marginRight: '8px', + display: 'inline-block', + width: '14px', + height: '4px', + }), + forcedColors: css({ + '@media (forced-colors: active)': { + forcedColorAdjust: 'none', + }, + }), +}); + +SeriesIcon.displayName = 'SeriesIcon'; diff --git a/packages/grafana-ui/src/components/VizLegend2/VizLegend2.tsx b/packages/grafana-ui/src/components/VizLegend2/VizLegend2.tsx new file mode 100644 index 00000000000..351d7fb0adb --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend2/VizLegend2.tsx @@ -0,0 +1,139 @@ +import { useCallback } from 'react'; +import * as React from 'react'; + +import { DataHoverClearEvent, DataHoverEvent } from '@grafana/data'; +import { LegendDisplayMode } from '@grafana/schema'; + +import { SeriesVisibilityChangeMode, usePanelContext } from '../PanelChrome'; + +import { VizLegendList } from './VizLegendList'; +import { VizLegendTable } from './VizLegendTable'; +import { LegendProps, SeriesVisibilityChangeBehavior, VizLegendItem } from './types'; +import { mapMouseEventToMode } from './utils'; + +/** + * @public + */ +export function VizLegend2({ + items, + thresholdItems, + displayMode, + sortBy: sortKey, + seriesVisibilityChangeBehavior = SeriesVisibilityChangeBehavior.Isolate, + sortDesc, + onLabelClick, + onToggleSort, + placement, + className, + itemRenderer, + readonly, + isSortable, +}: LegendProps) { + const { eventBus, onToggleSeriesVisibility, onToggleLegendSort } = usePanelContext(); + + const onMouseOver = useCallback( + ( + item: VizLegendItem, + event: React.MouseEvent | React.FocusEvent + ) => { + eventBus?.publish({ + type: DataHoverEvent.type, + payload: { + raw: event, + x: 0, + y: 0, + dataId: item.label, + }, + }); + }, + [eventBus] + ); + + const onMouseOut = useCallback( + ( + item: VizLegendItem, + event: React.MouseEvent | React.FocusEvent + ) => { + eventBus?.publish({ + type: DataHoverClearEvent.type, + payload: { + raw: event, + x: 0, + y: 0, + dataId: item.label, + }, + }); + }, + [eventBus] + ); + + const onLegendLabelClick = useCallback( + (item: VizLegendItem, event: React.MouseEvent) => { + if (onLabelClick) { + onLabelClick(item, event); + } + if (onToggleSeriesVisibility) { + onToggleSeriesVisibility( + item.fieldName ?? item.label, + seriesVisibilityChangeBehavior === SeriesVisibilityChangeBehavior.Hide + ? SeriesVisibilityChangeMode.AppendToSelection + : mapMouseEventToMode(event) + ); + } + }, + [onToggleSeriesVisibility, onLabelClick, seriesVisibilityChangeBehavior] + ); + + switch (displayMode) { + case LegendDisplayMode.Table: + return ( + + className={className} + items={items} + placement={placement} + sortBy={sortKey} + sortDesc={sortDesc} + onLabelClick={onLegendLabelClick} + onToggleSort={onToggleSort || onToggleLegendSort} + onLabelMouseOver={onMouseOver} + onLabelMouseOut={onMouseOut} + itemRenderer={itemRenderer} + readonly={readonly} + isSortable={isSortable} + /> + ); + case LegendDisplayMode.List: + return ( + <> + {/* render series colors if more than one series or no threshold colors */} + {(items.length > 1 || thresholdItems?.length === 0) && ( + + className={className} + items={items} + placement={placement} + onLabelMouseOver={onMouseOver} + onLabelMouseOut={onMouseOut} + onLabelClick={onLegendLabelClick} + itemRenderer={itemRenderer} + readonly={readonly} + /> + )} + {/* render threshold colors if exists */} + {thresholdItems && ( + + className={className} + items={thresholdItems} + placement={placement} + onLabelMouseOver={onMouseOver} + onLabelMouseOut={onMouseOut} + onLabelClick={onLegendLabelClick} + itemRenderer={itemRenderer} + readonly={readonly} + /> + )} + + ); + default: + return null; + } +} diff --git a/packages/grafana-ui/src/components/VizLegend2/VizLegendList.tsx b/packages/grafana-ui/src/components/VizLegend2/VizLegendList.tsx new file mode 100644 index 00000000000..2de3007a5da --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend2/VizLegendList.tsx @@ -0,0 +1,121 @@ +import { css, cx } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; +import { InlineList } from '../List/InlineList'; +import { List } from '../List/List'; + +import { VizLegendListItem } from './VizLegendListItem'; +import { VizLegendBaseProps, VizLegendItem } from './types'; + +export interface Props extends VizLegendBaseProps {} + +/** + * @internal + */ +export const VizLegendList = ({ + items, + itemRenderer, + onLabelMouseOver, + onLabelMouseOut, + onLabelClick, + placement, + className, + readonly, +}: Props) => { + const styles = useStyles2(getStyles); + + if (!itemRenderer) { + /* eslint-disable-next-line react/display-name */ + itemRenderer = (item) => ( + + ); + } + + const getItemKey = (item: VizLegendItem) => `${item.getItemKey ? item.getItemKey() : item.label}`; + + switch (placement) { + case 'right': { + const renderItem = (item: VizLegendItem, index: number) => { + return {itemRenderer!(item, index)}; + }; + + return ( +
+ +
+ ); + } + case 'bottom': + default: { + const leftItems = items.filter((item) => item.yAxis === 1); + const rightItems = items.filter((item) => item.yAxis !== 1); + + const renderItem = (item: VizLegendItem, index: number) => { + return {itemRenderer!(item, index)}; + }; + + return ( +
+ {leftItems.length > 0 && ( +
+ +
+ )} + {rightItems.length > 0 && ( +
+ +
+ )} +
+ ); + } + } +}; + +VizLegendList.displayName = 'VizLegendList'; + +const getStyles = (theme: GrafanaTheme2) => { + const itemStyles = css({ + paddingRight: '10px', + display: 'flex', + fontSize: theme.typography.bodySmall.fontSize, + whiteSpace: 'nowrap', + }); + + return { + itemBottom: itemStyles, + itemRight: cx( + itemStyles, + css({ + marginBottom: theme.spacing(0.5), + }) + ), + rightWrapper: css({ + paddingLeft: theme.spacing(0.5), + }), + bottomWrapper: css({ + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-between', + width: '100%', + paddingLeft: theme.spacing(0.5), + gap: '15px 25px', + }), + section: css({ + display: 'flex', + }), + sectionRight: css({ + justifyContent: 'flex-end', + flexGrow: 1, + flexBasis: '50%', + }), + }; +}; diff --git a/packages/grafana-ui/src/components/VizLegend2/VizLegendListItem.tsx b/packages/grafana-ui/src/components/VizLegend2/VizLegendListItem.tsx new file mode 100644 index 00000000000..4b6280a0f86 --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend2/VizLegendListItem.tsx @@ -0,0 +1,128 @@ +import { css, cx } from '@emotion/css'; +import { useCallback } from 'react'; +import * as React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; + +import { useStyles2 } from '../../themes'; + +import { VizLegendSeriesIcon } from './VizLegendSeriesIcon'; +import { VizLegendStatsList } from './VizLegendStatsList'; +import { VizLegendItem } from './types'; + +export interface Props { + item: VizLegendItem; + className?: string; + onLabelClick?: (item: VizLegendItem, event: React.MouseEvent) => void; + onLabelMouseOver?: ( + item: VizLegendItem, + event: React.MouseEvent | React.FocusEvent + ) => void; + onLabelMouseOut?: ( + item: VizLegendItem, + event: React.MouseEvent | React.FocusEvent + ) => void; + readonly?: boolean; +} + +/** + * @internal + */ +export const VizLegendListItem = ({ + item, + onLabelClick, + onLabelMouseOver, + onLabelMouseOut, + className, + readonly, +}: Props) => { + const styles = useStyles2(getStyles); + + const onMouseOver = useCallback( + (event: React.MouseEvent | React.FocusEvent) => { + if (onLabelMouseOver) { + onLabelMouseOver(item, event); + } + }, + [item, onLabelMouseOver] + ); + + const onMouseOut = useCallback( + (event: React.MouseEvent | React.FocusEvent) => { + if (onLabelMouseOut) { + onLabelMouseOut(item, event); + } + }, + [item, onLabelMouseOut] + ); + + const onClick = useCallback( + (event: React.MouseEvent) => { + if (onLabelClick) { + onLabelClick(item, event); + } + }, + [item, onLabelClick] + ); + + return ( +
+ + + + {item.getDisplayValues && } +
+ ); +}; + +VizLegendListItem.displayName = 'VizLegendListItem'; + +const getStyles = (theme: GrafanaTheme2) => ({ + label: css({ + label: 'LegendLabel', + whiteSpace: 'nowrap', + background: 'none', + border: 'none', + fontSize: 'inherit', + padding: 0, + userSelect: 'text', + }), + itemDisabled: css({ + label: 'LegendLabelDisabled', + color: theme.colors.text.disabled, + }), + itemWrapper: css({ + label: 'LegendItemWrapper', + display: 'flex', + whiteSpace: 'nowrap', + alignItems: 'center', + flexGrow: 1, + }), + value: css({ + textAlign: 'right', + }), + yAxisLabel: css({ + color: theme.v1.palette.gray2, + }), +}); diff --git a/packages/grafana-ui/src/components/VizLegend2/VizLegendSeriesIcon.tsx b/packages/grafana-ui/src/components/VizLegend2/VizLegendSeriesIcon.tsx new file mode 100644 index 00000000000..aa565e4ee64 --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend2/VizLegendSeriesIcon.tsx @@ -0,0 +1,49 @@ +import { memo, useCallback } from 'react'; + +import { LineStyle } from '@grafana/schema'; + +import { SeriesColorPicker } from '../ColorPicker/ColorPicker'; +import { usePanelContext } from '../PanelChrome'; + +import { SeriesIcon } from './SeriesIcon'; + +interface Props { + seriesName: string; + color?: string; + gradient?: string; + readonly?: boolean; + lineStyle?: LineStyle; +} + +/** + * @internal + */ +export const VizLegendSeriesIcon = memo(({ seriesName, color, gradient, readonly, lineStyle }: Props) => { + const { onSeriesColorChange } = usePanelContext(); + const onChange = useCallback( + (color: string) => { + return onSeriesColorChange!(seriesName, color); + }, + [seriesName, onSeriesColorChange] + ); + + if (seriesName && onSeriesColorChange && color && !readonly) { + return ( + + {({ ref, showColorPicker, hideColorPicker }) => ( + + )} + + ); + } + return ; +}); + +VizLegendSeriesIcon.displayName = 'VizLegendSeriesIcon'; diff --git a/packages/grafana-ui/src/components/VizLegend2/VizLegendStatsList.tsx b/packages/grafana-ui/src/components/VizLegend2/VizLegendStatsList.tsx new file mode 100644 index 00000000000..84de78ed3a9 --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend2/VizLegendStatsList.tsx @@ -0,0 +1,46 @@ +import { css } from '@emotion/css'; +import { capitalize } from 'lodash'; + +import { DisplayValue, formattedValueToString } from '@grafana/data'; + +import { useStyles2 } from '../../themes/ThemeContext'; +import { InlineList } from '../List/InlineList'; + +interface Props { + stats: DisplayValue[]; +} + +/** + * @internal + */ +export const VizLegendStatsList = ({ stats }: Props) => { + const styles = useStyles2(getStyles); + + if (stats.length === 0) { + return null; + } + + return ( + ( +
+ {stat.title && `${capitalize(stat.title)}:`} {formattedValueToString(stat)} +
+ )} + /> + ); +}; + +const getStyles = () => ({ + list: css({ + flexGrow: 1, + textAlign: 'right', + }), + item: css({ + marginLeft: '8px', + }), +}); + +VizLegendStatsList.displayName = 'VizLegendStatsList'; diff --git a/packages/grafana-ui/src/components/VizLegend2/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend2/VizLegendTable.tsx new file mode 100644 index 00000000000..0383aa1eab1 --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend2/VizLegendTable.tsx @@ -0,0 +1,145 @@ +import { css, cx } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes/ThemeContext'; +import { Icon } from '../Icon/Icon'; + +import { LegendTableItem } from './VizLegendTableItem'; +import { VizLegendItem, VizLegendTableProps } from './types'; + +const nameSortKey = 'Name'; +const naturalCompare = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare; + +/** + * @internal + */ +export const VizLegendTable = ({ + items, + sortBy: sortKey, + sortDesc, + itemRenderer, + className, + onToggleSort, + onLabelClick, + onLabelMouseOver, + onLabelMouseOut, + readonly, + isSortable, +}: VizLegendTableProps): JSX.Element => { + const styles = useStyles2(getStyles); + const header: Record = {}; + + if (isSortable) { + header[nameSortKey] = ''; + } + + for (const item of items) { + if (item.getDisplayValues) { + for (const displayValue of item.getDisplayValues()) { + header[displayValue.title ?? '?'] = displayValue.description ?? ''; + } + } + } + + if (sortKey != null) { + let itemVals = new Map(); + + items.forEach((item) => { + if (sortKey !== nameSortKey && item.getDisplayValues) { + const stat = item.getDisplayValues().find((stat) => stat.title === sortKey); + const val = stat == null || Number.isNaN(stat.numeric) ? -Infinity : stat.numeric; + itemVals.set(item, val); + } + }); + + let sortMult = sortDesc ? -1 : 1; + + if (sortKey === nameSortKey) { + // string sort + items.sort((a, b) => { + return sortMult * naturalCompare(a.label, b.label); + }); + } else { + // numeric sort + items.sort((a, b) => { + const aVal = itemVals.get(a) ?? 0; + const bVal = itemVals.get(b) ?? 0; + + return sortMult * (aVal - bVal); + }); + } + } + + if (!itemRenderer) { + /* eslint-disable-next-line react/display-name */ + itemRenderer = (item, index) => ( + + ); + } + + return ( + + + + {!isSortable && } + {Object.keys(header).map((columnTitle) => ( + + ))} + + + {items.map(itemRenderer!)} +
{ + if (onToggleSort) { + onToggleSort(columnTitle); + } + }} + > + {columnTitle} + {sortKey === columnTitle && } +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + table: css({ + width: '100%', + 'th:first-child': { + width: '100%', + borderBottom: `1px solid ${theme.colors.border.weak}`, + }, + }), + header: css({ + color: theme.colors.primary.text, + fontWeight: theme.typography.fontWeightMedium, + borderBottom: `1px solid ${theme.colors.border.weak}`, + padding: theme.spacing(0.25, 1, 0.25, 1), + fontSize: theme.typography.bodySmall.fontSize, + textAlign: 'right', + whiteSpace: 'nowrap', + }), + nameHeader: css({ + textAlign: 'left', + paddingLeft: '30px', + }), + // This needs to be padding-right - icon size(xs==12) to avoid jumping + withIcon: css({ + paddingRight: '4px', + }), + headerSortable: css({ + cursor: 'pointer', + }), +}); diff --git a/packages/grafana-ui/src/components/VizLegend2/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend2/VizLegendTableItem.tsx new file mode 100644 index 00000000000..3784bbabd5f --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend2/VizLegendTableItem.tsx @@ -0,0 +1,153 @@ +import { css, cx } from '@emotion/css'; +import { useCallback } from 'react'; +import * as React from 'react'; + +import { formattedValueToString, GrafanaTheme2 } from '@grafana/data'; + +import { styleMixins } from '../../themes'; +import { useStyles2 } from '../../themes/ThemeContext'; + +import { VizLegendSeriesIcon } from './VizLegendSeriesIcon'; +import { VizLegendItem } from './types'; + +export interface Props { + key?: React.Key; + item: VizLegendItem; + className?: string; + onLabelClick?: (item: VizLegendItem, event: React.MouseEvent) => void; + onLabelMouseOver?: ( + item: VizLegendItem, + event: React.MouseEvent | React.FocusEvent + ) => void; + onLabelMouseOut?: ( + item: VizLegendItem, + event: React.MouseEvent | React.FocusEvent + ) => void; + readonly?: boolean; +} + +/** + * @internal + */ +export const LegendTableItem = ({ + item, + onLabelClick, + onLabelMouseOver, + onLabelMouseOut, + className, + readonly, +}: Props) => { + const styles = useStyles2(getStyles); + + const onMouseOver = useCallback( + (event: React.MouseEvent | React.FocusEvent) => { + if (onLabelMouseOver) { + onLabelMouseOver(item, event); + } + }, + [item, onLabelMouseOver] + ); + + const onMouseOut = useCallback( + (event: React.MouseEvent | React.FocusEvent) => { + if (onLabelMouseOut) { + onLabelMouseOut(item, event); + } + }, + [item, onLabelMouseOut] + ); + + const onClick = useCallback( + (event: React.MouseEvent) => { + if (onLabelClick) { + onLabelClick(item, event); + } + }, + [item, onLabelClick] + ); + + return ( + + + + + + + + {item.getDisplayValues && + item.getDisplayValues().map((stat, index) => { + return ( + + {formattedValueToString(stat)} + + ); + })} + + ); +}; + +LegendTableItem.displayName = 'LegendTableItem'; + +const getStyles = (theme: GrafanaTheme2) => { + const rowHoverBg = styleMixins.hoverColor(theme.colors.background.primary, theme); + + return { + row: css({ + label: 'LegendRow', + fontSize: theme.v1.typography.size.sm, + borderBottom: `1px solid ${theme.colors.border.weak}`, + td: { + padding: theme.spacing(0.25, 1), + whiteSpace: 'nowrap', + }, + + '&:hover': { + background: rowHoverBg, + }, + }), + label: css({ + label: 'LegendLabel', + whiteSpace: 'nowrap', + background: 'none', + border: 'none', + fontSize: 'inherit', + padding: 0, + maxWidth: '600px', + textOverflow: 'ellipsis', + overflow: 'hidden', + userSelect: 'text', + }), + labelDisabled: css({ + label: 'LegendLabelDisabled', + color: theme.colors.text.disabled, + }), + itemWrapper: css({ + display: 'flex', + whiteSpace: 'nowrap', + alignItems: 'center', + }), + value: css({ + textAlign: 'right', + }), + yAxisLabel: css({ + color: theme.colors.text.secondary, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/VizLegend2/types.ts b/packages/grafana-ui/src/components/VizLegend2/types.ts new file mode 100644 index 00000000000..b6edadd7bff --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend2/types.ts @@ -0,0 +1,54 @@ +import * as React from 'react'; + +import { DataFrameFieldIndex, DisplayValue } from '@grafana/data'; +import { LegendDisplayMode, LegendPlacement, LineStyle } from '@grafana/schema'; + +export enum SeriesVisibilityChangeBehavior { + Isolate, + Hide, +} + +export interface VizLegendBaseProps { + placement: LegendPlacement; + className?: string; + items: Array>; + thresholdItems?: Array>; + seriesVisibilityChangeBehavior?: SeriesVisibilityChangeBehavior; + onLabelClick?: (item: VizLegendItem, event: React.MouseEvent) => void; + itemRenderer?: (item: VizLegendItem, index: number) => JSX.Element; + onLabelMouseOver?: ( + item: VizLegendItem, + event: React.MouseEvent | React.FocusEvent + ) => void; + onLabelMouseOut?: ( + item: VizLegendItem, + event: React.MouseEvent | React.FocusEvent + ) => void; + readonly?: boolean; +} + +export interface VizLegendTableProps extends VizLegendBaseProps { + sortBy?: string; + sortDesc?: boolean; + onToggleSort?: (sortBy: string) => void; + isSortable?: boolean; +} + +export interface LegendProps extends VizLegendBaseProps, VizLegendTableProps { + displayMode: LegendDisplayMode; +} + +export interface VizLegendItem { + getItemKey?: () => string; + label: string; + color?: string; + gradient?: string; + yAxis: number; + disabled?: boolean; + // displayValues?: DisplayValue[]; + getDisplayValues?: () => DisplayValue[]; + fieldIndex?: DataFrameFieldIndex; + fieldName?: string; + data?: T; + lineStyle?: LineStyle; +} diff --git a/packages/grafana-ui/src/components/VizLegend2/utils.ts b/packages/grafana-ui/src/components/VizLegend2/utils.ts new file mode 100644 index 00000000000..eff81da2dbf --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend2/utils.ts @@ -0,0 +1,8 @@ +import { SeriesVisibilityChangeMode } from '../PanelChrome/types'; + +export function mapMouseEventToMode(event: React.MouseEvent): SeriesVisibilityChangeMode { + if (event.ctrlKey || event.metaKey || event.shiftKey) { + return SeriesVisibilityChangeMode.AppendToSelection; + } + return SeriesVisibilityChangeMode.ToggleSelection; +} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index e2e21fb8af3..e76966bbe9e 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -148,6 +148,7 @@ export { } from './VizLayout/VizLayout'; export { type VizLegendItem, SeriesVisibilityChangeBehavior } from './VizLegend/types'; export { VizLegend } from './VizLegend/VizLegend'; +export { VizLegend2 } from './VizLegend2/VizLegend2'; export { VizLegendListItem } from './VizLegend/VizLegendListItem'; export { Alert, type AlertVariant } from './Alert/Alert'; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d240573d451..7168a9424ed 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1406,6 +1406,13 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaDashboardsSquad, }, + { + Name: "newVizLegend", + Description: "Enhanced legend for visualizations", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDatavizSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b4e658c3b06..c46e383b9bd 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -185,3 +185,4 @@ prometheusAzureOverrideAudience,deprecated,@grafana/partner-datasources,false,fa backgroundPluginInstaller,experimental,@grafana/plugins-platform-backend,false,true,false dataplaneAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false adhocFilterOneOf,experimental,@grafana/dashboards-squad,false,false,false +newVizLegend,experimental,@grafana/dataviz-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index bd81612d795..240db9692e7 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -750,4 +750,8 @@ const ( // FlagAdhocFilterOneOf // Exposes a new 'one of' operator for ad-hoc filters. This operator allows users to filter by multiple values in a single filter. FlagAdhocFilterOneOf = "adhocFilterOneOf" + + // FlagNewVizLegend + // Enhanced legend for visualizations + FlagNewVizLegend = "newVizLegend" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index bbee7f72edc..3c1df9145a8 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1837,6 +1837,19 @@ "codeowner": "@grafana/sharing-squad" } }, + { + "metadata": { + "name": "newVizLegend", + "resourceVersion": "1724158935069", + "creationTimestamp": "2024-08-20T13:02:15Z" + }, + "spec": { + "description": "Enhanced legend for visualizations", + "stage": "experimental", + "codeowner": "@grafana/dataviz-squad", + "frontend": true + } + }, { "metadata": { "name": "nodeGraphDotLayout", diff --git a/public/app/plugins/panel/barchart/BarChartLegend.tsx b/public/app/plugins/panel/barchart/BarChartLegend.tsx index dd13d2301ba..0c96ba17d9a 100644 --- a/public/app/plugins/panel/barchart/BarChartLegend.tsx +++ b/public/app/plugins/panel/barchart/BarChartLegend.tsx @@ -2,9 +2,18 @@ import { memo } from 'react'; import { DataFrame, Field, getFieldSeriesColor } from '@grafana/data'; import { VizLegendOptions, AxisPlacement } from '@grafana/schema'; -import { UPlotConfigBuilder, VizLayout, VizLayoutLegendProps, VizLegend, VizLegendItem, useTheme2 } from '@grafana/ui'; +import { + UPlotConfigBuilder, + VizLayout, + VizLayoutLegendProps, + VizLegend, + VizLegend2, + VizLegendItem, + useTheme2, +} from '@grafana/ui'; import { getDisplayValuesForCalcs } from '@grafana/ui/src/components/uPlot/utils'; -import { getFieldLegendItem } from 'app/core/components/TimelineChart/utils'; +import { getThresholdItems } from 'app/core/components/TimelineChart/utils'; +import { config } from 'app/core/config'; interface BarChartLegend2Props extends VizLegendOptions, Omit { data: DataFrame[]; @@ -32,17 +41,26 @@ export const BarChartLegend = memo( ({ data, placement, calcs, displayMode, colorField, ...vizLayoutLegendProps }: BarChartLegend2Props) => { const theme = useTheme2(); - if (colorField != null) { - const items = getFieldLegendItem([colorField], theme); + // if (colorField != null) { + // const items = getFieldLegendItem([colorField], theme); - if (items?.length) { - return ( - - - - ); - } - } + // if (items?.length) { + // if (config.featureToggles.newVizLegend) { + // return ( + // + // ); + // } else { + // return ( + // + // + // + // ); + // } + // } + // } + + const fieldConfig = data[0].fields[0].config; + const legendThresholdItems: VizLegendItem[] | undefined = getThresholdItems(fieldConfig, theme); const legendItems = data[0].fields .slice(1) @@ -75,18 +93,32 @@ export const BarChartLegend = memo( }) .filter((i): i is VizLegendItem => i !== undefined); - return ( - - - - ); + ); + } else { + return ( + + + + ); + } } );