mirror of
https://github.com/grafana/grafana.git
synced 2025-09-18 14:43:15 +08:00
Legends: Refactoring and rewrites of legend components to simplify components & reuse (#30165)
* Legends: Refactoring and rewrites of legend components to simplify components & reuse * Removed onSeriesAxisToggle * More removal of onSeriesAxisToggle and storybook improvements * Added story with legend values * Move table legend styles from inline to defined in stylesFactory * Update styles * Change to circle * Updated style to fat line * Rename to VizLegend * More renamed and fixes / polish * Removed imports * Minor change * Updates * Updates
This commit is contained in:
@ -13,7 +13,7 @@ import {
|
||||
import { useTheme } from '../../themes';
|
||||
import { HorizontalGroup } from '../Layout/Layout';
|
||||
import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay';
|
||||
import { SeriesIcon } from '../Legend/SeriesIcon';
|
||||
import { SeriesIcon } from '../VizLegend/SeriesIcon';
|
||||
import { css } from 'emotion';
|
||||
|
||||
export type ContextDimensions<T extends Dimensions = any> = { [key in keyof T]: [number, number | undefined] | null };
|
||||
|
@ -1,106 +0,0 @@
|
||||
import React from 'react';
|
||||
import { GraphLegend } from './GraphLegend';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { select, number } from '@storybook/addon-knobs';
|
||||
import { withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { generateLegendItems } from '../Legend/Legend';
|
||||
import { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
|
||||
|
||||
export default {
|
||||
title: 'Visualizations/Graph/GraphLegend',
|
||||
component: GraphLegend,
|
||||
decorators: [withHorizontallyCenteredStory],
|
||||
};
|
||||
|
||||
const getStoriesKnobs = (isList = false) => {
|
||||
const statsToDisplay = select<any>(
|
||||
'Stats to display',
|
||||
{
|
||||
none: [],
|
||||
'single (min)': [{ text: '10ms', title: 'min', numeric: 10 }],
|
||||
'multiple (min, max)': [
|
||||
{ text: '10ms', title: 'min', numeric: 10 },
|
||||
{ text: '100ms', title: 'max', numeric: 100 },
|
||||
],
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const numberOfSeries = number('Number of series', 3);
|
||||
|
||||
const containerWidth = select(
|
||||
'Container width',
|
||||
{
|
||||
Small: '200px',
|
||||
Medium: '500px',
|
||||
'Full width': '100%',
|
||||
},
|
||||
'100%'
|
||||
);
|
||||
|
||||
const legendPlacement = select<LegendPlacement>(
|
||||
'Legend placement',
|
||||
{
|
||||
bottom: 'bottom',
|
||||
right: 'right',
|
||||
},
|
||||
'bottom'
|
||||
);
|
||||
|
||||
return {
|
||||
statsToDisplay,
|
||||
numberOfSeries,
|
||||
containerWidth,
|
||||
legendPlacement,
|
||||
};
|
||||
};
|
||||
|
||||
export const list = () => {
|
||||
const { statsToDisplay, numberOfSeries, containerWidth, legendPlacement } = getStoriesKnobs(true);
|
||||
return (
|
||||
<div style={{ width: containerWidth }}>
|
||||
<GraphLegend
|
||||
displayMode={LegendDisplayMode.List}
|
||||
items={generateLegendItems(numberOfSeries, statsToDisplay)}
|
||||
onLabelClick={(item, event) => {
|
||||
action('Series label clicked')(item, event);
|
||||
}}
|
||||
onSeriesColorChange={(label, color) => {
|
||||
action('Series color changed')(label, color);
|
||||
}}
|
||||
onSeriesAxisToggle={(label, useRightYAxis) => {
|
||||
action('Series axis toggle')(label, useRightYAxis);
|
||||
}}
|
||||
onToggleSort={sortBy => {
|
||||
action('Toggle legend sort')(sortBy);
|
||||
}}
|
||||
placement={legendPlacement}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const table = () => {
|
||||
const { statsToDisplay, numberOfSeries, containerWidth, legendPlacement } = getStoriesKnobs();
|
||||
return (
|
||||
<div style={{ width: containerWidth }}>
|
||||
<GraphLegend
|
||||
displayMode={LegendDisplayMode.Table}
|
||||
items={generateLegendItems(numberOfSeries, statsToDisplay)}
|
||||
onLabelClick={item => {
|
||||
action('Series label clicked')(item);
|
||||
}}
|
||||
onSeriesColorChange={(label, color) => {
|
||||
action('Series color changed')(label, color);
|
||||
}}
|
||||
onSeriesAxisToggle={(label, useRightYAxis) => {
|
||||
action('Series axis toggle')(label, useRightYAxis);
|
||||
}}
|
||||
onToggleSort={sortBy => {
|
||||
action('Toggle legend sort')(sortBy);
|
||||
}}
|
||||
placement={legendPlacement}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,116 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { LegendProps, LegendItem, LegendDisplayMode } from '../Legend/Legend';
|
||||
import { GraphLegendListItem, GraphLegendTableRow } from './GraphLegendItem';
|
||||
import { SeriesColorChangeHandler, SeriesAxisToggleHandler } from './GraphWithLegend';
|
||||
import { LegendTable } from '../Legend/LegendTable';
|
||||
import { LegendList } from '../Legend/LegendList';
|
||||
import union from 'lodash/union';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { ThemeContext } from '../../themes/ThemeContext';
|
||||
import { css } from 'emotion';
|
||||
|
||||
export interface GraphLegendProps extends LegendProps {
|
||||
displayMode: LegendDisplayMode;
|
||||
sortBy?: string;
|
||||
sortDesc?: boolean;
|
||||
onSeriesColorChange?: SeriesColorChangeHandler;
|
||||
onSeriesAxisToggle?: SeriesAxisToggleHandler;
|
||||
onToggleSort?: (sortBy: string) => void;
|
||||
onLabelClick?: (item: LegendItem, event: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
export const GraphLegend: React.FunctionComponent<GraphLegendProps> = ({
|
||||
items,
|
||||
displayMode,
|
||||
sortBy: sortKey,
|
||||
sortDesc,
|
||||
onToggleSort,
|
||||
onSeriesAxisToggle,
|
||||
placement,
|
||||
className,
|
||||
...graphLegendItemProps
|
||||
}) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
|
||||
if (displayMode === LegendDisplayMode.Table) {
|
||||
const columns = items
|
||||
.map(item => {
|
||||
if (item.displayValues) {
|
||||
return item.displayValues.map(i => i.title);
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.reduce(
|
||||
(acc, current) => {
|
||||
return union(
|
||||
acc,
|
||||
current.filter(item => !!item)
|
||||
);
|
||||
},
|
||||
['']
|
||||
) as string[];
|
||||
|
||||
const sortedItems = sortKey
|
||||
? sortBy(items, item => {
|
||||
if (item.displayValues) {
|
||||
const stat = item.displayValues.filter(stat => stat.title === sortKey)[0];
|
||||
return stat && stat.numeric;
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
: items;
|
||||
|
||||
const legendTableEvenRowBackground = theme.isDark ? theme.palette.dark6 : theme.palette.gray5;
|
||||
|
||||
return (
|
||||
<LegendTable
|
||||
className={css`
|
||||
font-size: ${theme.typography.size.sm};
|
||||
th {
|
||||
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
|
||||
}
|
||||
`}
|
||||
items={sortDesc ? sortedItems.reverse() : sortedItems}
|
||||
columns={columns}
|
||||
placement={placement}
|
||||
sortBy={sortKey}
|
||||
sortDesc={sortDesc}
|
||||
itemRenderer={(item, index) => (
|
||||
<GraphLegendTableRow
|
||||
key={`${item.label}-${index}`}
|
||||
item={item}
|
||||
onToggleAxis={() => {
|
||||
if (onSeriesAxisToggle) {
|
||||
onSeriesAxisToggle(item.label, item.yAxis === 1 ? 2 : 1);
|
||||
}
|
||||
}}
|
||||
className={css`
|
||||
background: ${index % 2 === 0 ? legendTableEvenRowBackground : 'none'};
|
||||
`}
|
||||
{...graphLegendItemProps}
|
||||
/>
|
||||
)}
|
||||
onToggleSort={onToggleSort}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LegendList
|
||||
items={items}
|
||||
placement={placement}
|
||||
itemRenderer={item => (
|
||||
<GraphLegendListItem
|
||||
item={item}
|
||||
onToggleAxis={() => {
|
||||
if (onSeriesAxisToggle) {
|
||||
onSeriesAxisToggle(item.label, item.yAxis === 1 ? 2 : 1);
|
||||
}
|
||||
}}
|
||||
{...graphLegendItemProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
GraphLegend.displayName = 'GraphLegend';
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { stylesFactory } from '../../../themes/stylesFactory';
|
||||
import { GrafanaTheme, GraphSeriesValue } from '@grafana/data';
|
||||
import { css, cx } from 'emotion';
|
||||
import { SeriesIcon } from '../../Legend/SeriesIcon';
|
||||
import { SeriesIcon } from '../../VizLegend/SeriesIcon';
|
||||
import { useTheme } from '../../../themes';
|
||||
|
||||
export interface SeriesTableRowProps {
|
||||
|
@ -3,8 +3,7 @@ import React from 'react';
|
||||
import { select, text } from '@storybook/addon-knobs';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { GraphWithLegend, GraphWithLegendProps } from './GraphWithLegend';
|
||||
|
||||
import { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
|
||||
import { LegendPlacement, LegendDisplayMode } from '../VizLegend/types';
|
||||
import { GraphSeriesXY, FieldType, ArrayVector, dateTime, FieldColorModeId } from '@grafana/data';
|
||||
|
||||
export default {
|
||||
|
@ -5,21 +5,19 @@ import { css } from 'emotion';
|
||||
import { GraphSeriesValue } from '@grafana/data';
|
||||
|
||||
import { Graph, GraphProps } from './Graph';
|
||||
import { LegendRenderOptions, LegendItem, LegendDisplayMode } from '../Legend/Legend';
|
||||
import { GraphLegend } from './GraphLegend';
|
||||
import { VizLegendItem, LegendDisplayMode, SeriesColorChangeHandler, LegendPlacement } from '../VizLegend/types';
|
||||
import { VizLegend } from '../VizLegend/VizLegend';
|
||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||
import { stylesFactory } from '../../themes';
|
||||
|
||||
export type SeriesOptionChangeHandler<TOption> = (label: string, option: TOption) => void;
|
||||
export type SeriesColorChangeHandler = SeriesOptionChangeHandler<string>;
|
||||
export type SeriesAxisToggleHandler = SeriesOptionChangeHandler<number>;
|
||||
|
||||
export interface GraphWithLegendProps extends GraphProps, LegendRenderOptions {
|
||||
export interface GraphWithLegendProps extends GraphProps {
|
||||
legendDisplayMode: LegendDisplayMode;
|
||||
placement: LegendPlacement;
|
||||
hideEmpty?: boolean;
|
||||
hideZero?: boolean;
|
||||
sortLegendBy?: string;
|
||||
sortLegendDesc?: boolean;
|
||||
onSeriesColorChange?: SeriesColorChangeHandler;
|
||||
onSeriesAxisToggle?: SeriesAxisToggleHandler;
|
||||
onSeriesToggle?: (label: string, event: React.MouseEvent<HTMLElement>) => void;
|
||||
onToggleSort: (sortBy: string) => void;
|
||||
}
|
||||
@ -60,7 +58,6 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
|
||||
sortLegendDesc,
|
||||
legendDisplayMode,
|
||||
placement,
|
||||
onSeriesAxisToggle,
|
||||
onSeriesColorChange,
|
||||
onSeriesToggle,
|
||||
onToggleSort,
|
||||
@ -75,7 +72,7 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
|
||||
} = props;
|
||||
const { graphContainer, wrapper, legendContainer } = getGraphWithLegendStyles(props);
|
||||
|
||||
const legendItems = series.reduce<LegendItem[]>((acc, s) => {
|
||||
const legendItems = series.reduce<VizLegendItem[]>((acc, s) => {
|
||||
return shouldHideLegendItem(s.data, hideEmpty, hideZero)
|
||||
? acc
|
||||
: acc.concat([
|
||||
@ -112,7 +109,7 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
|
||||
{legendDisplayMode !== LegendDisplayMode.Hidden && (
|
||||
<div className={legendContainer}>
|
||||
<CustomScrollbar hideHorizontalTrack>
|
||||
<GraphLegend
|
||||
<VizLegend
|
||||
items={legendItems}
|
||||
displayMode={legendDisplayMode}
|
||||
placement={placement}
|
||||
@ -124,7 +121,6 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
|
||||
}
|
||||
}}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
onSeriesAxisToggle={onSeriesAxisToggle}
|
||||
onToggleSort={onToggleSort}
|
||||
/>
|
||||
</CustomScrollbar>
|
||||
|
@ -3,7 +3,7 @@ import React from 'react';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { GraphNG } from './GraphNG';
|
||||
import { dateTime } from '@grafana/data';
|
||||
import { LegendDisplayMode } from '../Legend/Legend';
|
||||
import { LegendDisplayMode } from '../VizLegend/types';
|
||||
import { prepDataForStorybook } from '../../utils/storybook/data';
|
||||
import { useTheme } from '../../themes';
|
||||
import { text, select } from '@storybook/addon-knobs';
|
||||
|
@ -16,8 +16,8 @@ import { PlotProps } from '../uPlot/types';
|
||||
import { AxisPlacement, DrawStyle, GraphFieldConfig, PointVisibility } from '../uPlot/config';
|
||||
import { useTheme } from '../../themes';
|
||||
import { VizLayout } from '../VizLayout/VizLayout';
|
||||
import { LegendDisplayMode, LegendItem, LegendOptions } from '../Legend/Legend';
|
||||
import { GraphLegend } from '../Graph/GraphLegend';
|
||||
import { LegendDisplayMode, VizLegendItem, VizLegendOptions } from '../VizLegend/types';
|
||||
import { VizLegend } from '../VizLegend/VizLegend';
|
||||
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||
import { useRevision } from '../uPlot/hooks';
|
||||
import { GraphNGLegendEvent, GraphNGLegendEventMode } from './types';
|
||||
@ -30,7 +30,7 @@ export interface XYFieldMatchers {
|
||||
}
|
||||
export interface GraphNGProps extends Omit<PlotProps, 'data' | 'config'> {
|
||||
data: DataFrame[];
|
||||
legend?: LegendOptions;
|
||||
legend?: VizLegendOptions;
|
||||
fields?: XYFieldMatchers; // default will assume timeseries data
|
||||
onLegendClick?: (event: GraphNGLegendEvent) => void;
|
||||
}
|
||||
@ -55,7 +55,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
}) => {
|
||||
const alignedFrameWithGapTest = useMemo(() => alignDataFrames(data, fields), [data, fields]);
|
||||
const theme = useTheme();
|
||||
const legendItemsRef = useRef<LegendItem[]>([]);
|
||||
const legendItemsRef = useRef<VizLegendItem[]>([]);
|
||||
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
|
||||
const alignedFrame = alignedFrameWithGapTest?.frame;
|
||||
const getDataFrameFieldIndex = alignedFrameWithGapTest?.getDataFrameFieldIndex;
|
||||
@ -68,7 +68,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
}, []);
|
||||
|
||||
const onLabelClick = useCallback(
|
||||
(legend: LegendItem, event: React.MouseEvent) => {
|
||||
(legend: VizLegendItem, event: React.MouseEvent) => {
|
||||
const { fieldIndex } = legend;
|
||||
|
||||
if (!onLegendClick || !fieldIndex) {
|
||||
@ -128,7 +128,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
const legendItems: LegendItem[] = [];
|
||||
const legendItems: VizLegendItem[] = [];
|
||||
|
||||
for (let i = 0; i < alignedFrame.fields.length; i++) {
|
||||
const field = alignedFrame.fields[i];
|
||||
@ -217,7 +217,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
if (hasLegend && legendItemsRef.current.length > 0) {
|
||||
legendElement = (
|
||||
<VizLayout.Legend position={legend!.placement} maxHeight="35%" maxWidth="60%">
|
||||
<GraphLegend
|
||||
<VizLegend
|
||||
onLabelClick={onLabelClick}
|
||||
placement={legend!.placement}
|
||||
items={legendItemsRef.current}
|
||||
|
@ -3,7 +3,7 @@ import { DataFrameFieldIndex } from '@grafana/data';
|
||||
/**
|
||||
* Mode to describe if a legend is isolated/selected or being appended to an existing
|
||||
* series selection.
|
||||
* @public
|
||||
* @alpha
|
||||
*/
|
||||
export enum GraphNGLegendEventMode {
|
||||
ToggleSelection = 'select',
|
||||
@ -12,7 +12,7 @@ export enum GraphNGLegendEventMode {
|
||||
|
||||
/**
|
||||
* Event being triggered when the user interact with the Graph legend.
|
||||
* @public
|
||||
* @alpha
|
||||
*/
|
||||
export interface GraphNGLegendEvent {
|
||||
fieldIndex: DataFrameFieldIndex;
|
||||
|
@ -1,131 +0,0 @@
|
||||
import React from 'react';
|
||||
import { generateLegendItems } from './Legend';
|
||||
import { LegendList, LegendPlacement, LegendItem, LegendTable } from '@grafana/ui';
|
||||
import { number, select, text } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { GraphLegendListItem, GraphLegendTableRow, GraphLegendItemProps } from '../Graph/GraphLegendItem';
|
||||
|
||||
const getStoriesKnobs = (table = false) => {
|
||||
const numberOfSeries = number('Number of series', 3);
|
||||
const containerWidth = select(
|
||||
'Container width',
|
||||
{
|
||||
Small: '200px',
|
||||
Medium: '500px',
|
||||
'Full width': '100%',
|
||||
},
|
||||
'100%'
|
||||
);
|
||||
|
||||
const rawRenderer = (item: LegendItem) => (
|
||||
<>
|
||||
Label: <strong>{item.label}</strong>, Color: <strong>{item.color}</strong>, disabled:{' '}
|
||||
<strong>{item.disabled ? 'yes' : 'no'}</strong>
|
||||
</>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const customRenderer = (component: React.ComponentType<GraphLegendItemProps>) => (item: LegendItem) =>
|
||||
React.createElement(component, {
|
||||
item,
|
||||
onLabelClick: action('GraphLegendItem label clicked'),
|
||||
onSeriesColorChange: action('Series color changed'),
|
||||
onToggleAxis: action('Y-axis toggle'),
|
||||
});
|
||||
|
||||
const typeSpecificRenderer = table
|
||||
? {
|
||||
'Custom renderer(GraphLegendTablerow)': 'custom-table',
|
||||
}
|
||||
: {
|
||||
'Custom renderer(GraphLegendListItem)': 'custom-list',
|
||||
};
|
||||
const legendItemRenderer = select(
|
||||
'Item rendered',
|
||||
{
|
||||
'Raw renderer': 'raw',
|
||||
...typeSpecificRenderer,
|
||||
},
|
||||
'raw'
|
||||
);
|
||||
|
||||
const rightAxisSeries = text('Right y-axis series, i.e. A,C', '');
|
||||
|
||||
const legendPlacement = select<LegendPlacement>(
|
||||
'Legend placement',
|
||||
{
|
||||
bottom: 'bottom',
|
||||
right: 'right',
|
||||
},
|
||||
'bottom'
|
||||
);
|
||||
|
||||
return {
|
||||
numberOfSeries,
|
||||
containerWidth,
|
||||
itemRenderer:
|
||||
legendItemRenderer === 'raw'
|
||||
? rawRenderer
|
||||
: customRenderer(legendItemRenderer === 'custom-list' ? GraphLegendListItem : GraphLegendTableRow),
|
||||
rightAxisSeries,
|
||||
legendPlacement,
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Visualizations/Legend',
|
||||
component: LegendList,
|
||||
subcomponents: { LegendTable },
|
||||
};
|
||||
|
||||
export const list = () => {
|
||||
const { numberOfSeries, itemRenderer, containerWidth, rightAxisSeries, legendPlacement } = getStoriesKnobs();
|
||||
let items = generateLegendItems(numberOfSeries);
|
||||
|
||||
items = items.map(i => {
|
||||
if (
|
||||
rightAxisSeries
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.indexOf(i.label.split('-')[0]) > -1
|
||||
) {
|
||||
i.yAxis = 2;
|
||||
}
|
||||
|
||||
return i;
|
||||
});
|
||||
return (
|
||||
<div style={{ width: containerWidth }}>
|
||||
<LegendList itemRenderer={itemRenderer} items={items} placement={legendPlacement} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const table = () => {
|
||||
const { numberOfSeries, itemRenderer, containerWidth, rightAxisSeries, legendPlacement } = getStoriesKnobs(true);
|
||||
let items = generateLegendItems(numberOfSeries);
|
||||
|
||||
items = items.map(i => {
|
||||
if (
|
||||
rightAxisSeries
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.indexOf(i.label.split('-')[0]) > -1
|
||||
) {
|
||||
i.yAxis = 2;
|
||||
}
|
||||
|
||||
return {
|
||||
...i,
|
||||
info: [
|
||||
{ title: 'min', text: '14.42', numeric: 14.427101844163694 },
|
||||
{ title: 'max', text: '18.42', numeric: 18.427101844163694 },
|
||||
],
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div style={{ width: containerWidth }}>
|
||||
<LegendTable itemRenderer={itemRenderer} items={items} columns={['', 'min', 'max']} placement={legendPlacement} />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,58 +0,0 @@
|
||||
import { DataFrameFieldIndex, DisplayValue } from '@grafana/data';
|
||||
|
||||
import { LegendList } from './LegendList';
|
||||
import { LegendTable } from './LegendTable';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
export const generateLegendItems = (numberOfSeries: number, statsToDisplay?: DisplayValue[]): LegendItem[] => {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz'.split('');
|
||||
|
||||
return [...new Array(numberOfSeries)].map((item, i) => {
|
||||
return {
|
||||
label: `${alphabet[i].toUpperCase()}-series`,
|
||||
color: tinycolor.fromRatio({ h: i / alphabet.length, s: 1, v: 1 }).toHexString(),
|
||||
yAxis: 1,
|
||||
displayValues: statsToDisplay || [],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export enum LegendDisplayMode {
|
||||
List = 'list',
|
||||
Table = 'table',
|
||||
Hidden = 'hidden',
|
||||
}
|
||||
export interface LegendBasicOptions {
|
||||
displayMode: LegendDisplayMode;
|
||||
}
|
||||
|
||||
export interface LegendRenderOptions {
|
||||
placement: LegendPlacement;
|
||||
hideEmpty?: boolean;
|
||||
hideZero?: boolean;
|
||||
}
|
||||
|
||||
export type LegendPlacement = 'bottom' | 'right';
|
||||
|
||||
export interface LegendOptions extends LegendBasicOptions, LegendRenderOptions {}
|
||||
|
||||
export interface LegendItem {
|
||||
label: string;
|
||||
color: string;
|
||||
yAxis: number;
|
||||
disabled?: boolean;
|
||||
displayValues?: DisplayValue[];
|
||||
fieldIndex?: DataFrameFieldIndex;
|
||||
}
|
||||
|
||||
export interface LegendComponentProps {
|
||||
className?: string;
|
||||
items: LegendItem[];
|
||||
placement: LegendPlacement;
|
||||
// Function to render given item
|
||||
itemRenderer?: (item: LegendItem, index: number) => JSX.Element;
|
||||
}
|
||||
|
||||
export interface LegendProps extends LegendComponentProps {}
|
||||
|
||||
export { LegendList, LegendTable };
|
@ -1,61 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { LegendComponentProps, LegendItem } from './Legend';
|
||||
import { InlineList } from '../List/InlineList';
|
||||
import { List } from '../List/List';
|
||||
import { css, cx } from 'emotion';
|
||||
import { ThemeContext } from '../../themes/ThemeContext';
|
||||
import { stylesFactory } from '../../themes';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||
item: css`
|
||||
padding-left: 10px;
|
||||
display: flex;
|
||||
font-size: ${theme.typography.size.sm};
|
||||
white-space: nowrap;
|
||||
`,
|
||||
wrapper: css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
`,
|
||||
section: css`
|
||||
display: flex;
|
||||
`,
|
||||
sectionRight: css`
|
||||
justify-content: flex-end;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const LegendList: React.FunctionComponent<LegendComponentProps> = ({
|
||||
items,
|
||||
itemRenderer,
|
||||
placement,
|
||||
className,
|
||||
}) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const styles = getStyles(theme);
|
||||
|
||||
const renderItem = (item: LegendItem, index: number) => {
|
||||
return <span className={styles.item}>{itemRenderer ? itemRenderer(item, index) : item.label}</span>;
|
||||
};
|
||||
|
||||
const getItemKey = (item: LegendItem) => `${item.label}`;
|
||||
|
||||
return placement === 'bottom' ? (
|
||||
<div className={cx(styles.wrapper, className)}>
|
||||
<div className={styles.section}>
|
||||
<InlineList items={items.filter(item => item.yAxis === 1)} renderItem={renderItem} getItemKey={getItemKey} />
|
||||
</div>
|
||||
<div className={cx(styles.section, styles.sectionRight)}>
|
||||
<InlineList items={items.filter(item => item.yAxis !== 1)} renderItem={renderItem} getItemKey={getItemKey} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<List items={items} renderItem={renderItem} getItemKey={getItemKey} className={className} />
|
||||
);
|
||||
};
|
||||
|
||||
LegendList.displayName = 'LegendList';
|
@ -1,61 +0,0 @@
|
||||
import React from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { SeriesColorPicker } from '../ColorPicker/ColorPicker';
|
||||
import { SeriesIcon, SeriesIconProps } from './SeriesIcon';
|
||||
|
||||
interface LegendSeriesIconProps {
|
||||
disabled: boolean;
|
||||
color: string;
|
||||
yAxis: number;
|
||||
onColorChange: (color: string) => void;
|
||||
onToggleAxis?: () => void;
|
||||
}
|
||||
|
||||
export const LegendSeriesIcon: React.FunctionComponent<LegendSeriesIconProps> = ({
|
||||
disabled,
|
||||
yAxis,
|
||||
color,
|
||||
onColorChange,
|
||||
onToggleAxis,
|
||||
}) => {
|
||||
let iconProps: SeriesIconProps = {
|
||||
color,
|
||||
};
|
||||
|
||||
if (!disabled) {
|
||||
iconProps = {
|
||||
...iconProps,
|
||||
className: 'pointer',
|
||||
};
|
||||
}
|
||||
|
||||
return disabled ? (
|
||||
<span
|
||||
className={cx(
|
||||
'graph-legend-icon',
|
||||
disabled &&
|
||||
css`
|
||||
cursor: default;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<SeriesIcon {...iconProps} />
|
||||
</span>
|
||||
) : (
|
||||
<SeriesColorPicker
|
||||
yaxis={yAxis}
|
||||
color={color}
|
||||
onChange={onColorChange}
|
||||
onToggleAxis={onToggleAxis}
|
||||
enableNamedColors
|
||||
>
|
||||
{({ ref, showColorPicker, hideColorPicker }) => (
|
||||
<span ref={ref} onClick={showColorPicker} onMouseLeave={hideColorPicker} className="graph-legend-icon">
|
||||
<SeriesIcon {...iconProps} />
|
||||
</span>
|
||||
)}
|
||||
</SeriesColorPicker>
|
||||
);
|
||||
};
|
||||
|
||||
LegendSeriesIcon.displayName = 'LegendSeriesIcon';
|
@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import { InlineList } from '../List/InlineList';
|
||||
import { css } from 'emotion';
|
||||
import { DisplayValue, formattedValueToString } from '@grafana/data';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
|
||||
const LegendItemStat: React.FunctionComponent<{ stat: DisplayValue }> = ({ stat }) => {
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
margin-left: 6px;
|
||||
`}
|
||||
>
|
||||
{stat.title && `${capitalize(stat.title)}:`} {formattedValueToString(stat)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LegendItemStat.displayName = 'LegendItemStat';
|
||||
|
||||
export const LegendStatsList: React.FunctionComponent<{ stats: DisplayValue[] }> = ({ stats }) => {
|
||||
if (stats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return <InlineList items={stats} renderItem={stat => <LegendItemStat stat={stat} />} />;
|
||||
};
|
||||
|
||||
LegendStatsList.displayName = 'LegendStatsList';
|
@ -1,82 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { LegendComponentProps } from './Legend';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { ThemeContext } from '../../themes/ThemeContext';
|
||||
|
||||
export interface LegendTableProps extends LegendComponentProps {
|
||||
columns: string[];
|
||||
sortBy?: string;
|
||||
sortDesc?: boolean;
|
||||
onToggleSort?: (sortBy: string) => void;
|
||||
}
|
||||
|
||||
export const LegendTable: React.FunctionComponent<LegendTableProps> = ({
|
||||
items,
|
||||
columns,
|
||||
sortBy,
|
||||
sortDesc,
|
||||
itemRenderer,
|
||||
className,
|
||||
onToggleSort,
|
||||
}) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
|
||||
return (
|
||||
<table
|
||||
className={cx(
|
||||
css`
|
||||
width: 100%;
|
||||
td {
|
||||
padding: 2px 10px;
|
||||
}
|
||||
`,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(columnHeader => {
|
||||
return (
|
||||
<th
|
||||
key={columnHeader}
|
||||
className={css`
|
||||
color: ${theme.colors.textBlue};
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
`}
|
||||
onClick={() => {
|
||||
if (onToggleSort) {
|
||||
onToggleSort(columnHeader);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columnHeader}
|
||||
{sortBy === columnHeader && (
|
||||
<Icon
|
||||
className={css`
|
||||
margin-left: ${theme.spacing.sm};
|
||||
`}
|
||||
name={sortDesc ? 'angle-down' : 'angle-up'}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => {
|
||||
return itemRenderer ? (
|
||||
itemRenderer(item, index)
|
||||
) : (
|
||||
<tr key={`${item.label}-${index}`}>
|
||||
<td>{item.label}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
@ -1,13 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
export interface SeriesIconProps {
|
||||
color: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SeriesIcon: React.FunctionComponent<SeriesIconProps> = ({ color, className }) => {
|
||||
return <Icon name="minus" className={className} style={{ color }} />;
|
||||
};
|
||||
|
||||
SeriesIcon.displayName = 'SeriesIcon';
|
20
packages/grafana-ui/src/components/VizLegend/SeriesIcon.tsx
Normal file
20
packages/grafana-ui/src/components/VizLegend/SeriesIcon.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
|
||||
export interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const SeriesIcon = React.forwardRef<HTMLDivElement, Props>(({ color, className, ...restProps }, ref) => {
|
||||
const styles: CSSProperties = {
|
||||
backgroundColor: color,
|
||||
width: '14px',
|
||||
height: '4px',
|
||||
borderRadius: '1px',
|
||||
display: 'inline-block',
|
||||
marginRight: '8px',
|
||||
};
|
||||
|
||||
return <div ref={ref} className={className} style={styles} {...restProps} />;
|
||||
});
|
||||
|
||||
SeriesIcon.displayName = 'SeriesIcon';
|
184
packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx
Normal file
184
packages/grafana-ui/src/components/VizLegend/VizLegend.story.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import React, { FC, useState } from 'react';
|
||||
import { useTheme, VizLegend } from '@grafana/ui';
|
||||
import { number, select } from '@storybook/addon-knobs';
|
||||
import {} from './VizLegendListItem';
|
||||
import { DisplayValue, getColorForTheme, GrafanaTheme } from '@grafana/data';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { LegendDisplayMode, VizLegendItem, LegendPlacement } from './types';
|
||||
|
||||
const getStoriesKnobs = (table = false) => {
|
||||
const seriesCount = number('Number of series', 5);
|
||||
const containerWidth = select(
|
||||
'Container width',
|
||||
{
|
||||
Small: '200px',
|
||||
Medium: '500px',
|
||||
'Full width': '100%',
|
||||
},
|
||||
'100%'
|
||||
);
|
||||
|
||||
return {
|
||||
seriesCount,
|
||||
containerWidth,
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Visualizations/VizLegend',
|
||||
component: VizLegend,
|
||||
decorators: [withCenteredStory],
|
||||
};
|
||||
|
||||
interface LegendStoryDemoProps {
|
||||
name: string;
|
||||
displayMode: LegendDisplayMode;
|
||||
placement: LegendPlacement;
|
||||
seriesCount: number;
|
||||
stats?: DisplayValue[];
|
||||
}
|
||||
|
||||
const LegendStoryDemo: FC<LegendStoryDemoProps> = ({ displayMode, seriesCount, name, placement, stats }) => {
|
||||
const theme = useTheme();
|
||||
const [items, setItems] = useState<VizLegendItem[]>(generateLegendItems(seriesCount, theme, stats));
|
||||
|
||||
const onSeriesColorChange = (label: string, color: string) => {
|
||||
setItems(
|
||||
items.map(item => {
|
||||
if (item.label === label) {
|
||||
return {
|
||||
...item,
|
||||
color: color,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onLabelClick = (clickItem: VizLegendItem) => {
|
||||
setItems(
|
||||
items.map(item => {
|
||||
if (item !== clickItem) {
|
||||
return {
|
||||
...item,
|
||||
disabled: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...item,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<p style={{ marginBottom: '32px' }}>
|
||||
<h3 style={{ marginBottom: '32px' }}>{name}</h3>
|
||||
<VizLegend
|
||||
displayMode={displayMode}
|
||||
items={items}
|
||||
placement={placement}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
onLabelClick={onLabelClick}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithNoValues = () => {
|
||||
const { seriesCount, containerWidth } = getStoriesKnobs();
|
||||
|
||||
return (
|
||||
<div style={{ width: containerWidth }}>
|
||||
<LegendStoryDemo
|
||||
name="List mode, placement bottom"
|
||||
displayMode={LegendDisplayMode.List}
|
||||
seriesCount={seriesCount}
|
||||
placement="bottom"
|
||||
/>
|
||||
<LegendStoryDemo
|
||||
name="List mode, placement right"
|
||||
displayMode={LegendDisplayMode.List}
|
||||
seriesCount={seriesCount}
|
||||
placement="right"
|
||||
/>
|
||||
<LegendStoryDemo
|
||||
name="Table mode"
|
||||
displayMode={LegendDisplayMode.Table}
|
||||
seriesCount={seriesCount}
|
||||
placement="bottom"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WithValues = () => {
|
||||
const { seriesCount, containerWidth } = getStoriesKnobs();
|
||||
const stats: DisplayValue[] = [
|
||||
{
|
||||
title: 'Min',
|
||||
text: '5.00',
|
||||
numeric: 5,
|
||||
},
|
||||
{
|
||||
title: 'Max',
|
||||
text: '10.00',
|
||||
numeric: 10,
|
||||
},
|
||||
{
|
||||
title: 'Last',
|
||||
text: '2.00',
|
||||
numeric: 2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ width: containerWidth }}>
|
||||
<LegendStoryDemo
|
||||
name="List mode, placement bottom"
|
||||
displayMode={LegendDisplayMode.List}
|
||||
seriesCount={seriesCount}
|
||||
placement="bottom"
|
||||
stats={stats}
|
||||
/>
|
||||
<LegendStoryDemo
|
||||
name="List mode, placement right"
|
||||
displayMode={LegendDisplayMode.List}
|
||||
seriesCount={seriesCount}
|
||||
placement="right"
|
||||
stats={stats}
|
||||
/>
|
||||
<LegendStoryDemo
|
||||
name="Table mode"
|
||||
displayMode={LegendDisplayMode.Table}
|
||||
seriesCount={seriesCount}
|
||||
placement="bottom"
|
||||
stats={stats}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function generateLegendItems(
|
||||
numberOfSeries: number,
|
||||
theme: GrafanaTheme,
|
||||
statsToDisplay?: DisplayValue[]
|
||||
): VizLegendItem[] {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz'.split('');
|
||||
const colors = ['green', 'blue', 'red', 'purple', 'orange', 'dark-green', 'yellow', 'light-blue'].map(c =>
|
||||
getColorForTheme(c, theme)
|
||||
);
|
||||
|
||||
return [...new Array(numberOfSeries)].map((item, i) => {
|
||||
return {
|
||||
label: `${alphabet[i].toUpperCase()}-series`,
|
||||
color: colors[i],
|
||||
yAxis: 1,
|
||||
displayValues: statsToDisplay || [],
|
||||
};
|
||||
});
|
||||
}
|
46
packages/grafana-ui/src/components/VizLegend/VizLegend.tsx
Normal file
46
packages/grafana-ui/src/components/VizLegend/VizLegend.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { LegendProps, LegendDisplayMode } from './types';
|
||||
import { VizLegendTable } from './VizLegendTable';
|
||||
import { VizLegendList } from './VizLegendList';
|
||||
|
||||
export const VizLegend: React.FunctionComponent<LegendProps> = ({
|
||||
items,
|
||||
displayMode,
|
||||
sortBy: sortKey,
|
||||
sortDesc,
|
||||
onToggleSort,
|
||||
onLabelClick,
|
||||
onSeriesColorChange,
|
||||
placement,
|
||||
className,
|
||||
}) => {
|
||||
switch (displayMode) {
|
||||
case LegendDisplayMode.Table:
|
||||
return (
|
||||
<VizLegendTable
|
||||
className={className}
|
||||
items={items}
|
||||
placement={placement}
|
||||
sortBy={sortKey}
|
||||
sortDesc={sortDesc}
|
||||
onLabelClick={onLabelClick}
|
||||
onToggleSort={onToggleSort}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
/>
|
||||
);
|
||||
case LegendDisplayMode.List:
|
||||
return (
|
||||
<VizLegendList
|
||||
className={className}
|
||||
items={items}
|
||||
placement={placement}
|
||||
onLabelClick={onLabelClick}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
VizLegend.displayName = 'Legend';
|
@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { VizLegendBaseProps, VizLegendItem } from './types';
|
||||
import { InlineList } from '../List/InlineList';
|
||||
import { List } from '../List/List';
|
||||
import { css, cx } from 'emotion';
|
||||
import { useStyles } from '../../themes';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { VizLegendListItem } from './VizLegendListItem';
|
||||
|
||||
export interface Props extends VizLegendBaseProps {}
|
||||
|
||||
export const VizLegendList: React.FunctionComponent<Props> = ({
|
||||
items,
|
||||
itemRenderer,
|
||||
onSeriesColorChange,
|
||||
onLabelClick,
|
||||
placement,
|
||||
className,
|
||||
}) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
if (!itemRenderer) {
|
||||
/* eslint-disable-next-line react/display-name */
|
||||
itemRenderer = item => (
|
||||
<VizLegendListItem item={item} onLabelClick={onLabelClick} onSeriesColorChange={onSeriesColorChange} />
|
||||
);
|
||||
}
|
||||
|
||||
const renderItem = (item: VizLegendItem, index: number) => {
|
||||
return <span className={styles.item}>{itemRenderer!(item, index)}</span>;
|
||||
};
|
||||
|
||||
const getItemKey = (item: VizLegendItem) => `${item.label}`;
|
||||
|
||||
switch (placement) {
|
||||
case 'right':
|
||||
return (
|
||||
<div className={cx(styles.rightWrapper, className)}>
|
||||
<List items={items} renderItem={renderItem} getItemKey={getItemKey} className={className} />
|
||||
</div>
|
||||
);
|
||||
case 'bottom':
|
||||
default:
|
||||
return (
|
||||
<div className={cx(styles.bottomWrapper, className)}>
|
||||
<div className={styles.section}>
|
||||
<InlineList
|
||||
items={items.filter(item => item.yAxis === 1)}
|
||||
renderItem={renderItem}
|
||||
getItemKey={getItemKey}
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.section, styles.sectionRight)}>
|
||||
<InlineList
|
||||
items={items.filter(item => item.yAxis !== 1)}
|
||||
renderItem={renderItem}
|
||||
getItemKey={getItemKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
VizLegendList.displayName = 'VizLegendList';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
item: css`
|
||||
padding-right: 10px;
|
||||
display: flex;
|
||||
font-size: ${theme.typography.size.sm};
|
||||
white-space: nowrap;
|
||||
margin-bottom: ${theme.spacing.xs};
|
||||
`,
|
||||
rightWrapper: css`
|
||||
margin-left: ${theme.spacing.sm};
|
||||
`,
|
||||
bottomWrapper: css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-left: ${theme.spacing.md};
|
||||
`,
|
||||
section: css`
|
||||
display: flex;
|
||||
`,
|
||||
sectionRight: css`
|
||||
justify-content: flex-end;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
});
|
@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { VizLegendSeriesIcon } from './VizLegendSeriesIcon';
|
||||
import { VizLegendItem } from './types';
|
||||
import { SeriesColorChangeHandler } from './types';
|
||||
import { VizLegendStatsList } from './VizLegendStatsList';
|
||||
import { useStyles } from '../../themes';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
export interface Props {
|
||||
item: VizLegendItem;
|
||||
className?: string;
|
||||
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onSeriesColorChange?: SeriesColorChangeHandler;
|
||||
}
|
||||
|
||||
export const VizLegendListItem: React.FunctionComponent<Props> = ({ item, onSeriesColorChange, onLabelClick }) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.itemWrapper}>
|
||||
<VizLegendSeriesIcon
|
||||
disabled={!onSeriesColorChange}
|
||||
color={item.color}
|
||||
onColorChange={color => {
|
||||
if (onSeriesColorChange) {
|
||||
onSeriesColorChange(item.label, color);
|
||||
}
|
||||
}}
|
||||
yAxis={item.yAxis}
|
||||
/>
|
||||
<div
|
||||
onClick={event => {
|
||||
if (onLabelClick) {
|
||||
onLabelClick(item, event);
|
||||
}
|
||||
}}
|
||||
className={cx(styles.label, item.disabled && styles.labelDisabled)}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
|
||||
{item.displayValues && <VizLegendStatsList stats={item.displayValues} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
VizLegendListItem.displayName = 'VizLegendListItem';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
label: css`
|
||||
label: LegendLabel;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
labelDisabled: css`
|
||||
label: LegendLabelDisabled;
|
||||
color: ${theme.colors.linkDisabled};
|
||||
`,
|
||||
itemWrapper: css`
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
`,
|
||||
value: css`
|
||||
text-align: right;
|
||||
`,
|
||||
yAxisLabel: css`
|
||||
color: ${theme.palette.gray2};
|
||||
`,
|
||||
});
|
@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { SeriesColorPicker } from '../ColorPicker/ColorPicker';
|
||||
import { SeriesIcon } from './SeriesIcon';
|
||||
|
||||
interface Props {
|
||||
disabled: boolean;
|
||||
color: string;
|
||||
yAxis: number;
|
||||
onColorChange: (color: string) => void;
|
||||
onToggleAxis?: () => void;
|
||||
}
|
||||
|
||||
export const VizLegendSeriesIcon: React.FunctionComponent<Props> = ({
|
||||
disabled,
|
||||
yAxis,
|
||||
color,
|
||||
onColorChange,
|
||||
onToggleAxis,
|
||||
}) => {
|
||||
return disabled ? (
|
||||
<SeriesIcon color={color} />
|
||||
) : (
|
||||
<SeriesColorPicker
|
||||
yaxis={yAxis}
|
||||
color={color}
|
||||
onChange={onColorChange}
|
||||
onToggleAxis={onToggleAxis}
|
||||
enableNamedColors
|
||||
>
|
||||
{({ ref, showColorPicker, hideColorPicker }) => (
|
||||
<SeriesIcon
|
||||
color={color}
|
||||
className="pointer"
|
||||
ref={ref}
|
||||
onClick={showColorPicker}
|
||||
onMouseLeave={hideColorPicker}
|
||||
/>
|
||||
)}
|
||||
</SeriesColorPicker>
|
||||
);
|
||||
};
|
||||
|
||||
VizLegendSeriesIcon.displayName = 'VizLegendSeriesIcon';
|
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { InlineList } from '../List/InlineList';
|
||||
import { css } from 'emotion';
|
||||
import { DisplayValue, formattedValueToString } from '@grafana/data';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
|
||||
const VizLegendItemStat: React.FunctionComponent<{ stat: DisplayValue }> = ({ stat }) => {
|
||||
const styles = css`
|
||||
margin-left: 8px;
|
||||
`;
|
||||
|
||||
return (
|
||||
<div className={styles}>
|
||||
{stat.title && `${capitalize(stat.title)}:`} {formattedValueToString(stat)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
VizLegendItemStat.displayName = 'VizLegendItemStat';
|
||||
|
||||
export const VizLegendStatsList: React.FunctionComponent<{ stats: DisplayValue[] }> = ({ stats }) => {
|
||||
if (stats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return <InlineList items={stats} renderItem={stat => <VizLegendItemStat stat={stat} />} />;
|
||||
};
|
||||
|
||||
VizLegendStatsList.displayName = 'VizLegendStatsList';
|
107
packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx
Normal file
107
packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import React, { FC } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { VizLegendTableProps } from './types';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { useStyles } from '../../themes/ThemeContext';
|
||||
import union from 'lodash/union';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import { LegendTableItem } from './VizLegendTableItem';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
export const VizLegendTable: FC<VizLegendTableProps> = ({
|
||||
items,
|
||||
sortBy: sortKey,
|
||||
sortDesc,
|
||||
itemRenderer,
|
||||
className,
|
||||
onToggleSort,
|
||||
onLabelClick,
|
||||
onSeriesColorChange,
|
||||
}) => {
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
const columns = items
|
||||
.map(item => {
|
||||
if (item.displayValues) {
|
||||
return item.displayValues.map(i => i.title);
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.reduce(
|
||||
(acc, current) => {
|
||||
return union(
|
||||
acc,
|
||||
current.filter(item => !!item)
|
||||
);
|
||||
},
|
||||
['']
|
||||
) as string[];
|
||||
|
||||
const sortedItems = sortKey
|
||||
? sortBy(items, item => {
|
||||
if (item.displayValues) {
|
||||
const stat = item.displayValues.filter(stat => stat.title === sortKey)[0];
|
||||
return stat && stat.numeric;
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
: items;
|
||||
|
||||
if (!itemRenderer) {
|
||||
/* eslint-disable-next-line react/display-name */
|
||||
itemRenderer = (item, index) => (
|
||||
<LegendTableItem
|
||||
key={`${item.label}-${index}`}
|
||||
item={item}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
onLabelClick={onLabelClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className={cx(styles.table, className)}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map(columnHeader => {
|
||||
return (
|
||||
<th
|
||||
key={columnHeader}
|
||||
className={styles.header}
|
||||
onClick={() => {
|
||||
if (onToggleSort) {
|
||||
onToggleSort(columnHeader);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columnHeader}
|
||||
{sortKey === columnHeader && (
|
||||
<Icon className={styles.sortIcon} name={sortDesc ? 'angle-down' : 'angle-up'} />
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{sortedItems.map(itemRenderer!)}</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
table: css`
|
||||
width: 100%;
|
||||
margin-left: ${theme.spacing.sm};
|
||||
`,
|
||||
header: css`
|
||||
color: ${theme.colors.textBlue};
|
||||
font-weight: ${theme.typography.weight.semibold};
|
||||
border-bottom: 1px solid ${theme.colors.border1};
|
||||
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
`,
|
||||
sortIcon: css`
|
||||
margin-left: ${theme.spacing.sm};
|
||||
`,
|
||||
});
|
@ -1,113 +1,40 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { LegendSeriesIcon } from '../Legend/LegendSeriesIcon';
|
||||
import { LegendItem } from '../Legend/Legend';
|
||||
import { SeriesColorChangeHandler } from './GraphWithLegend';
|
||||
import { LegendStatsList } from '../Legend/LegendStatsList';
|
||||
import { ThemeContext } from '../../themes/ThemeContext';
|
||||
import { stylesFactory } from '../../themes';
|
||||
import { VizLegendSeriesIcon } from './VizLegendSeriesIcon';
|
||||
import { VizLegendItem } from './types';
|
||||
import { SeriesColorChangeHandler } from './types';
|
||||
import { useStyles } from '../../themes/ThemeContext';
|
||||
import { styleMixins } from '../../themes';
|
||||
import { GrafanaTheme, formattedValueToString } from '@grafana/data';
|
||||
|
||||
export interface GraphLegendItemProps {
|
||||
export interface Props {
|
||||
key?: React.Key;
|
||||
item: LegendItem;
|
||||
item: VizLegendItem;
|
||||
className?: string;
|
||||
onLabelClick?: (item: LegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onSeriesColorChange?: SeriesColorChangeHandler;
|
||||
onToggleAxis?: () => void;
|
||||
}
|
||||
|
||||
export const GraphLegendListItem: React.FunctionComponent<GraphLegendItemProps> = ({
|
||||
export const LegendTableItem: React.FunctionComponent<Props> = ({
|
||||
item,
|
||||
onSeriesColorChange,
|
||||
onToggleAxis,
|
||||
onLabelClick,
|
||||
}) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const styles = getStyles(theme);
|
||||
return (
|
||||
<>
|
||||
<LegendSeriesIcon
|
||||
disabled={!onSeriesColorChange}
|
||||
color={item.color}
|
||||
onColorChange={color => {
|
||||
if (onSeriesColorChange) {
|
||||
onSeriesColorChange(item.label, color);
|
||||
}
|
||||
}}
|
||||
onToggleAxis={onToggleAxis}
|
||||
yAxis={item.yAxis}
|
||||
/>
|
||||
<div
|
||||
onClick={event => {
|
||||
if (onLabelClick) {
|
||||
onLabelClick(item, event);
|
||||
}
|
||||
}}
|
||||
className={cx(styles.label, item.disabled && styles.labelDisabled)}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
|
||||
{item.displayValues && <LegendStatsList stats={item.displayValues} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
row: css`
|
||||
label: LegendRow;
|
||||
font-size: ${theme.typography.size.sm};
|
||||
td {
|
||||
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
|
||||
white-space: nowrap;
|
||||
}
|
||||
`,
|
||||
label: css`
|
||||
label: LegendLabel;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
labelDisabled: css`
|
||||
label: LegendLabelDisabled;
|
||||
color: ${theme.colors.linkDisabled};
|
||||
`,
|
||||
itemWrapper: css`
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
value: css`
|
||||
text-align: right;
|
||||
`,
|
||||
yAxisLabel: css`
|
||||
color: ${theme.palette.gray2};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
export const GraphLegendTableRow: React.FunctionComponent<GraphLegendItemProps> = ({
|
||||
item,
|
||||
onSeriesColorChange,
|
||||
onToggleAxis,
|
||||
onLabelClick,
|
||||
className,
|
||||
}) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
const styles = getStyles(theme);
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
return (
|
||||
<tr className={cx(styles.row, className)}>
|
||||
<td>
|
||||
<span className={styles.itemWrapper}>
|
||||
<LegendSeriesIcon
|
||||
disabled={!!onSeriesColorChange}
|
||||
<VizLegendSeriesIcon
|
||||
disabled={!onSeriesColorChange}
|
||||
color={item.color}
|
||||
onColorChange={color => {
|
||||
if (onSeriesColorChange) {
|
||||
onSeriesColorChange(item.label, color);
|
||||
}
|
||||
}}
|
||||
onToggleAxis={onToggleAxis}
|
||||
yAxis={item.yAxis}
|
||||
/>
|
||||
<div
|
||||
@ -133,3 +60,45 @@ export const GraphLegendTableRow: React.FunctionComponent<GraphLegendItemProps>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
LegendTableItem.displayName = 'LegendTableItem';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
const rowHoverBg = styleMixins.hoverColor(theme.colors.bg1, theme);
|
||||
|
||||
return {
|
||||
row: css`
|
||||
label: LegendRow;
|
||||
font-size: ${theme.typography.size.sm};
|
||||
border-bottom: 1px solid ${theme.colors.border1};
|
||||
td {
|
||||
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${rowHoverBg};
|
||||
}
|
||||
`,
|
||||
label: css`
|
||||
label: LegendLabel;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
labelDisabled: css`
|
||||
label: LegendLabelDisabled;
|
||||
color: ${theme.colors.linkDisabled};
|
||||
`,
|
||||
itemWrapper: css`
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
`,
|
||||
value: css`
|
||||
text-align: right;
|
||||
`,
|
||||
yAxisLabel: css`
|
||||
color: ${theme.palette.gray2};
|
||||
`,
|
||||
};
|
||||
};
|
45
packages/grafana-ui/src/components/VizLegend/types.ts
Normal file
45
packages/grafana-ui/src/components/VizLegend/types.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { DataFrameFieldIndex, DisplayValue } from '@grafana/data';
|
||||
|
||||
export interface VizLegendBaseProps {
|
||||
placement: LegendPlacement;
|
||||
className?: string;
|
||||
items: VizLegendItem[];
|
||||
itemRenderer?: (item: VizLegendItem, index: number) => JSX.Element;
|
||||
onSeriesColorChange?: SeriesColorChangeHandler;
|
||||
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
export interface VizLegendTableProps extends VizLegendBaseProps {
|
||||
sortBy?: string;
|
||||
sortDesc?: boolean;
|
||||
onToggleSort?: (sortBy: string) => void;
|
||||
}
|
||||
|
||||
export interface LegendProps extends VizLegendBaseProps, VizLegendTableProps {
|
||||
displayMode: LegendDisplayMode;
|
||||
}
|
||||
|
||||
export interface VizLegendItem {
|
||||
label: string;
|
||||
color: string;
|
||||
yAxis: number;
|
||||
disabled?: boolean;
|
||||
displayValues?: DisplayValue[];
|
||||
fieldIndex?: DataFrameFieldIndex;
|
||||
}
|
||||
|
||||
export enum LegendDisplayMode {
|
||||
List = 'list',
|
||||
Table = 'table',
|
||||
Hidden = 'hidden',
|
||||
}
|
||||
|
||||
export type LegendPlacement = 'bottom' | 'right';
|
||||
|
||||
export interface VizLegendOptions {
|
||||
displayMode: LegendDisplayMode;
|
||||
placement: LegendPlacement;
|
||||
}
|
||||
|
||||
export type SeriesOptionChangeHandler<TOption> = (label: string, option: TOption) => void;
|
||||
export type SeriesColorChangeHandler = SeriesOptionChangeHandler<string>;
|
@ -69,7 +69,6 @@ export {
|
||||
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { GraphLegend } from './Graph/GraphLegend';
|
||||
export { GraphWithLegend } from './Graph/GraphWithLegend';
|
||||
export { GraphContextMenu, GraphContextMenuHeader } from './Graph/GraphContextMenu';
|
||||
export { BarGauge, BarGaugeDisplayMode } from './BarGauge/BarGauge';
|
||||
@ -77,17 +76,8 @@ export { GraphTooltipOptions } from './Graph/GraphTooltip/types';
|
||||
export { VizRepeater, VizRepeaterRenderValueProps } from './VizRepeater/VizRepeater';
|
||||
export { graphTimeFormat, graphTickFormatter } from './Graph/utils';
|
||||
export { VizLayout, VizLayoutComponentType, VizLayoutLegendProps, VizLayoutProps } from './VizLayout/VizLayout';
|
||||
|
||||
export {
|
||||
LegendOptions,
|
||||
LegendBasicOptions,
|
||||
LegendRenderOptions,
|
||||
LegendList,
|
||||
LegendTable,
|
||||
LegendItem,
|
||||
LegendPlacement,
|
||||
LegendDisplayMode,
|
||||
} from './Legend/Legend';
|
||||
export { VizLegendItem, LegendPlacement, LegendDisplayMode, VizLegendOptions } from './VizLegend/types';
|
||||
export { VizLegend } from './VizLegend/VizLegend';
|
||||
|
||||
export { Alert, AlertVariant } from './Alert/Alert';
|
||||
export { GraphSeriesToggler, GraphSeriesTogglerAPI } from './Graph/GraphSeriesToggler';
|
||||
@ -109,7 +99,7 @@ export { WithContextMenu } from './ContextMenu/WithContextMenu';
|
||||
export { DataLinksInlineEditor } from './DataLinks/DataLinksInlineEditor/DataLinksInlineEditor';
|
||||
export { DataLinkInput } from './DataLinks/DataLinkInput';
|
||||
export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
|
||||
export { SeriesIcon } from './Legend/SeriesIcon';
|
||||
export { SeriesIcon } from './VizLegend/SeriesIcon';
|
||||
export { InfoBox } from './InfoBox/InfoBox';
|
||||
export { FeatureInfoBox } from './InfoBox/FeatureInfoBox';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { LegendOptions, GraphTooltipOptions, LegendDisplayMode } from '@grafana/ui';
|
||||
import { GraphTooltipOptions, LegendDisplayMode, LegendPlacement } from '@grafana/ui';
|
||||
import { YAxis } from '@grafana/data';
|
||||
|
||||
export interface SeriesOptions {
|
||||
@ -14,7 +14,10 @@ export interface GraphOptions {
|
||||
|
||||
export interface Options {
|
||||
graph: GraphOptions;
|
||||
legend: LegendOptions & GraphLegendEditorLegendOptions;
|
||||
legend: {
|
||||
displayMode: LegendDisplayMode;
|
||||
placement: LegendPlacement;
|
||||
};
|
||||
series: {
|
||||
[alias: string]: SeriesOptions;
|
||||
};
|
||||
@ -35,7 +38,9 @@ export const defaults: Options = {
|
||||
tooltipOptions: { mode: 'single' },
|
||||
};
|
||||
|
||||
export interface GraphLegendEditorLegendOptions extends LegendOptions {
|
||||
export interface GraphLegendEditorLegendOptions {
|
||||
displayMode: LegendDisplayMode;
|
||||
placement: LegendPlacement;
|
||||
stats?: string[];
|
||||
decimals?: number;
|
||||
sortBy?: string;
|
||||
|
@ -197,7 +197,7 @@ class LegendSeriesIcon extends PureComponent<LegendSeriesIconProps, LegendSeries
|
||||
enableNamedColors
|
||||
>
|
||||
{({ ref, showColorPicker, hideColorPicker }) => (
|
||||
<span ref={ref} onClick={showColorPicker} onMouseLeave={hideColorPicker} className="graph-legend-icon">
|
||||
<span ref={ref} onClick={showColorPicker} onMouseLeave={hideColorPicker}>
|
||||
<SeriesIcon color={this.props.color} />
|
||||
</span>
|
||||
)}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { LegendOptions, GraphTooltipOptions } from '@grafana/ui';
|
||||
import { VizLegendOptions, GraphTooltipOptions } from '@grafana/ui';
|
||||
|
||||
export interface GraphOptions {
|
||||
// Redraw as time passes
|
||||
@ -7,13 +7,6 @@ export interface GraphOptions {
|
||||
|
||||
export interface Options {
|
||||
graph: GraphOptions;
|
||||
legend: LegendOptions;
|
||||
legend: VizLegendOptions;
|
||||
tooltipOptions: GraphTooltipOptions;
|
||||
}
|
||||
|
||||
export interface GraphLegendEditorLegendOptions extends LegendOptions {
|
||||
stats?: string[];
|
||||
decimals?: number;
|
||||
sortBy?: string;
|
||||
sortDesc?: boolean;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { LegendOptions, GraphTooltipOptions } from '@grafana/ui';
|
||||
import { VizLegendOptions, GraphTooltipOptions } from '@grafana/ui';
|
||||
|
||||
export interface XYDimensionConfig {
|
||||
frame: number;
|
||||
@ -9,6 +9,6 @@ export interface XYDimensionConfig {
|
||||
|
||||
export interface Options {
|
||||
dims: XYDimensionConfig;
|
||||
legend: LegendOptions;
|
||||
legend: VizLegendOptions;
|
||||
tooltipOptions: GraphTooltipOptions;
|
||||
}
|
||||
|
Reference in New Issue
Block a user