mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Feature: Migrate Legend components to grafana/ui (#16468)
* Introduced Abstract list, List and InlineList components for easier lists generation * Enable custom item key on abstract list items * Enable $.flot in storybook * Expose onOptionsChange to react panel. Allow React panels to be function components * Update type on graph panel options to group graph draw options * Introduce GraphPanelController for state and effects handling of new graph panel * Group visualisation related stories under Visualisations
This commit is contained in:
parent
7dadebb3f0
commit
739cdcfb6e
@ -2,7 +2,17 @@ import { configure, addDecorator } from '@storybook/react';
|
||||
import { withKnobs } from '@storybook/addon-knobs';
|
||||
import { withTheme } from '../src/utils/storybook/withTheme';
|
||||
import { withPaddedStory } from '../src/utils/storybook/withPaddedStory';
|
||||
|
||||
import 'jquery';
|
||||
import '../../../public/vendor/flot/jquery.flot.js';
|
||||
import '../../../public/vendor/flot/jquery.flot.selection';
|
||||
import '../../../public/vendor/flot/jquery.flot.time';
|
||||
import '../../../public/vendor/flot/jquery.flot.stack';
|
||||
import '../../../public/vendor/flot/jquery.flot.pie';
|
||||
import '../../../public/vendor/flot/jquery.flot.stackpercent';
|
||||
import '../../../public/vendor/flot/jquery.flot.fillbelow';
|
||||
import '../../../public/vendor/flot/jquery.flot.crosshair';
|
||||
import '../../../public/vendor/flot/jquery.flot.dashes';
|
||||
import '../../../public/vendor/flot/jquery.flot.gauge';
|
||||
// @ts-ignore
|
||||
import lightTheme from '../../../public/sass/grafana.light.scss';
|
||||
// @ts-ignore
|
||||
|
@ -2,6 +2,7 @@ import React, { Component } from 'react';
|
||||
import isNil from 'lodash/isNil';
|
||||
import classNames from 'classnames';
|
||||
import Scrollbars from 'react-custom-scrollbars';
|
||||
import { cx, css } from 'emotion';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@ -10,8 +11,8 @@ interface Props {
|
||||
autoHideDuration?: number;
|
||||
autoHeightMax?: string;
|
||||
hideTracksWhenNotNeeded?: boolean;
|
||||
renderTrackHorizontal?: React.FunctionComponent<any>;
|
||||
renderTrackVertical?: React.FunctionComponent<any>;
|
||||
hideHorizontalTrack?: boolean;
|
||||
hideVerticalTrack?: boolean;
|
||||
scrollTop?: number;
|
||||
setScrollTop: (event: any) => void;
|
||||
autoHeightMin?: number | string;
|
||||
@ -79,8 +80,8 @@ export class CustomScrollbar extends Component<Props> {
|
||||
autoHide,
|
||||
autoHideTimeout,
|
||||
hideTracksWhenNotNeeded,
|
||||
renderTrackHorizontal,
|
||||
renderTrackVertical,
|
||||
hideHorizontalTrack,
|
||||
hideVerticalTrack,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
@ -96,8 +97,28 @@ export class CustomScrollbar extends Component<Props> {
|
||||
// Before these where set to inhert but that caused problems with cut of legends in firefox
|
||||
autoHeightMax={autoHeightMax}
|
||||
autoHeightMin={autoHeightMin}
|
||||
renderTrackHorizontal={renderTrackHorizontal || (props => <div {...props} className="track-horizontal" />)}
|
||||
renderTrackVertical={renderTrackVertical || (props => <div {...props} className="track-vertical" />)}
|
||||
renderTrackHorizontal={props => (
|
||||
<div
|
||||
{...props}
|
||||
className={cx(
|
||||
css`
|
||||
visibility: ${hideHorizontalTrack ? 'none' : 'visible'};
|
||||
`,
|
||||
'track-horizontal'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
renderTrackVertical={props => (
|
||||
<div
|
||||
{...props}
|
||||
className={cx(
|
||||
css`
|
||||
visibility: ${hideVerticalTrack ? 'none' : 'visible'};
|
||||
`,
|
||||
'track-vertical'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
|
||||
renderThumbVertical={props => <div {...props} className="thumb-vertical" />}
|
||||
renderView={props => <div {...props} className="view" />}
|
||||
|
@ -37,7 +37,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="track-horizontal"
|
||||
className="css-17l4171 track-horizontal"
|
||||
style={
|
||||
Object {
|
||||
"display": "none",
|
||||
@ -58,7 +58,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="track-vertical"
|
||||
className="css-17l4171 track-vertical"
|
||||
style={
|
||||
Object {
|
||||
"display": "none",
|
||||
|
@ -1,11 +1,12 @@
|
||||
// Libraries
|
||||
import $ from 'jquery';
|
||||
import React, { PureComponent } from 'react';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
|
||||
// Types
|
||||
import { TimeRange, GraphSeriesXY } from '../../types';
|
||||
|
||||
interface GraphProps {
|
||||
export interface GraphProps {
|
||||
series: GraphSeriesXY[];
|
||||
timeRange: TimeRange; // NOTE: we should aim to make `time` a property of the axis, not force it for all graphs
|
||||
showLines?: boolean;
|
||||
@ -46,7 +47,16 @@ export class Graph extends PureComponent<GraphProps> {
|
||||
const ticks = width / 100;
|
||||
const min = timeRange.from.valueOf();
|
||||
const max = timeRange.to.valueOf();
|
||||
|
||||
const yaxes = uniqBy(
|
||||
series.map(s => {
|
||||
return {
|
||||
show: true,
|
||||
index: s.yAxis,
|
||||
position: s.yAxis === 1 ? 'left' : 'right',
|
||||
};
|
||||
}),
|
||||
yAxisConfig => yAxisConfig.index
|
||||
);
|
||||
const flotOptions = {
|
||||
legend: {
|
||||
show: false,
|
||||
@ -80,6 +90,7 @@ export class Graph extends PureComponent<GraphProps> {
|
||||
ticks: ticks,
|
||||
timeformat: timeFormat(ticks, min, max),
|
||||
},
|
||||
yaxes,
|
||||
grid: {
|
||||
minBorderMargin: 0,
|
||||
markings: [],
|
||||
|
105
packages/grafana-ui/src/components/Graph/GraphLegend.story.tsx
Normal file
105
packages/grafana-ui/src/components/Graph/GraphLegend.story.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/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.story';
|
||||
import { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
|
||||
|
||||
const GraphLegendStories = storiesOf('Visualizations/Graph/GraphLegend', module);
|
||||
GraphLegendStories.addDecorator(withHorizontallyCenteredStory);
|
||||
|
||||
const getStoriesKnobs = (isList = false) => {
|
||||
const statsToDisplay = select(
|
||||
'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',
|
||||
{
|
||||
under: 'under',
|
||||
right: 'right',
|
||||
},
|
||||
'under'
|
||||
);
|
||||
|
||||
return {
|
||||
statsToDisplay,
|
||||
numberOfSeries,
|
||||
containerWidth,
|
||||
legendPlacement,
|
||||
};
|
||||
};
|
||||
|
||||
GraphLegendStories.add('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>
|
||||
);
|
||||
});
|
||||
|
||||
GraphLegendStories.add('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>
|
||||
);
|
||||
});
|
118
packages/grafana-ui/src/components/Graph/GraphLegend.tsx
Normal file
118
packages/grafana-ui/src/components/Graph/GraphLegend.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
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';
|
||||
import { selectThemeVariant } from '../../themes/index';
|
||||
|
||||
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 = selectThemeVariant(
|
||||
{
|
||||
dark: theme.colors.dark6,
|
||||
light: theme.colors.gray5,
|
||||
},
|
||||
theme.type
|
||||
);
|
||||
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
117
packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx
Normal file
117
packages/grafana-ui/src/components/Graph/GraphLegendItem.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import React, { useContext } 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';
|
||||
|
||||
export interface GraphLegendItemProps {
|
||||
key?: React.Key;
|
||||
item: LegendItem;
|
||||
className?: string;
|
||||
onLabelClick: (item: LegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onSeriesColorChange: SeriesColorChangeHandler;
|
||||
onToggleAxis: () => void;
|
||||
}
|
||||
|
||||
export const GraphLegendListItem: React.FunctionComponent<GraphLegendItemProps> = ({
|
||||
item,
|
||||
onSeriesColorChange,
|
||||
onToggleAxis,
|
||||
onLabelClick,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<LegendSeriesIcon
|
||||
color={item.color}
|
||||
onColorChange={color => onSeriesColorChange(item.label, color)}
|
||||
onToggleAxis={onToggleAxis}
|
||||
yAxis={item.yAxis}
|
||||
/>
|
||||
<div
|
||||
onClick={event => onLabelClick(item, event)}
|
||||
className={css`
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
`}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
|
||||
{item.displayValues && <LegendStatsList stats={item.displayValues} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const GraphLegendTableRow: React.FunctionComponent<GraphLegendItemProps> = ({
|
||||
item,
|
||||
onSeriesColorChange,
|
||||
onToggleAxis,
|
||||
onLabelClick,
|
||||
className,
|
||||
}) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cx(
|
||||
css`
|
||||
font-size: ${theme.typography.size.sm};
|
||||
td {
|
||||
padding: ${theme.spacing.xxs} ${theme.spacing.sm};
|
||||
white-space: nowrap;
|
||||
}
|
||||
`,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<td>
|
||||
<span
|
||||
className={css`
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
`}
|
||||
>
|
||||
<LegendSeriesIcon
|
||||
color={item.color}
|
||||
onColorChange={color => onSeriesColorChange(item.label, color)}
|
||||
onToggleAxis={onToggleAxis}
|
||||
yAxis={item.yAxis}
|
||||
/>
|
||||
<div
|
||||
onClick={event => onLabelClick(item, event)}
|
||||
className={css`
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
`}
|
||||
>
|
||||
{item.label}{' '}
|
||||
{item.yAxis === 2 && (
|
||||
<span
|
||||
className={css`
|
||||
color: ${theme.colors.gray2};
|
||||
`}
|
||||
>
|
||||
(right y-axis)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
{item.displayValues &&
|
||||
item.displayValues.map((stat, index) => {
|
||||
return (
|
||||
<td
|
||||
className={css`
|
||||
text-align: right;
|
||||
`}
|
||||
key={`${stat.title}-${index}`}
|
||||
>
|
||||
{stat.text}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
};
|
@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import { select, text } from '@storybook/addon-knobs';
|
||||
import { withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { GraphWithLegend } from './GraphWithLegend';
|
||||
|
||||
import { mockGraphWithLegendData } from './mockGraphWithLegendData';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { LegendPlacement, LegendDisplayMode } from '../Legend/Legend';
|
||||
const GraphWithLegendStories = storiesOf('Visualizations/Graph/GraphWithLegend', module);
|
||||
GraphWithLegendStories.addDecorator(withHorizontallyCenteredStory);
|
||||
|
||||
const getStoriesKnobs = () => {
|
||||
const containerWidth = select(
|
||||
'Container width',
|
||||
{
|
||||
Small: '200px',
|
||||
Medium: '500px',
|
||||
'Full width': '100%',
|
||||
},
|
||||
'100%'
|
||||
);
|
||||
const containerHeight = select(
|
||||
'Container height',
|
||||
{
|
||||
Small: '200px',
|
||||
Medium: '400px',
|
||||
'Full height': '100%',
|
||||
},
|
||||
'400px'
|
||||
);
|
||||
|
||||
const rightAxisSeries = text('Right y-axis series, i.e. A,C', '');
|
||||
|
||||
const legendPlacement = select<LegendPlacement>(
|
||||
'Legend placement',
|
||||
{
|
||||
under: 'under',
|
||||
right: 'right',
|
||||
},
|
||||
'under'
|
||||
);
|
||||
const renderLegendAsTable = select(
|
||||
'Render legend as',
|
||||
{
|
||||
list: false,
|
||||
table: true,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
return {
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
rightAxisSeries,
|
||||
legendPlacement,
|
||||
renderLegendAsTable,
|
||||
};
|
||||
};
|
||||
|
||||
GraphWithLegendStories.add('default', () => {
|
||||
const { containerWidth, containerHeight, rightAxisSeries, legendPlacement, renderLegendAsTable } = getStoriesKnobs();
|
||||
|
||||
const props = mockGraphWithLegendData({
|
||||
onSeriesColorChange: action('Series color changed'),
|
||||
onSeriesAxisToggle: action('Series y-axis changed'),
|
||||
displayMode: renderLegendAsTable ? LegendDisplayMode.Table : LegendDisplayMode.List,
|
||||
});
|
||||
const series = props.series.map(s => {
|
||||
if (
|
||||
rightAxisSeries
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.indexOf(s.label.split('-')[0]) > -1
|
||||
) {
|
||||
s.yAxis = 2;
|
||||
}
|
||||
|
||||
return s;
|
||||
});
|
||||
return (
|
||||
<div style={{ width: containerWidth, height: containerHeight }}>
|
||||
<GraphWithLegend {...props} placement={legendPlacement} series={series} />,
|
||||
</div>
|
||||
);
|
||||
});
|
125
packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx
Normal file
125
packages/grafana-ui/src/components/Graph/GraphWithLegend.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { css } from 'emotion';
|
||||
import { Graph, GraphProps } from './Graph';
|
||||
import { LegendRenderOptions, LegendItem, LegendDisplayMode } from '../Legend/Legend';
|
||||
import { GraphLegend } from './GraphLegend';
|
||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||
import { GraphSeriesValue } from '../../types/graph';
|
||||
|
||||
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 {
|
||||
isLegendVisible: boolean;
|
||||
displayMode: LegendDisplayMode;
|
||||
sortLegendBy?: string;
|
||||
sortLegendDesc?: boolean;
|
||||
onSeriesColorChange: SeriesColorChangeHandler;
|
||||
onSeriesAxisToggle?: SeriesAxisToggleHandler;
|
||||
onSeriesToggle?: (label: string, event: React.MouseEvent<HTMLElement>) => void;
|
||||
onToggleSort: (sortBy: string) => void;
|
||||
}
|
||||
|
||||
const getGraphWithLegendStyles = ({ placement }: GraphWithLegendProps) => ({
|
||||
wrapper: css`
|
||||
display: flex;
|
||||
flex-direction: ${placement === 'under' ? 'column' : 'row'};
|
||||
height: 100%;
|
||||
`,
|
||||
graphContainer: css`
|
||||
min-height: 65%;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
legendContainer: css`
|
||||
padding: 10px 0;
|
||||
max-height: ${placement === 'under' ? '35%' : 'none'};
|
||||
`,
|
||||
});
|
||||
|
||||
const shouldHideLegendItem = (data: GraphSeriesValue[][], hideEmpty = false, hideZero = false) => {
|
||||
const isZeroOnlySeries = data.reduce((acc, current) => acc + (current[1] || 0), 0) === 0;
|
||||
const isNullOnlySeries = !data.reduce((acc, current) => acc && current[1] !== null, true);
|
||||
|
||||
return (hideEmpty && isNullOnlySeries) || (hideZero && isZeroOnlySeries);
|
||||
};
|
||||
|
||||
export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (props: GraphWithLegendProps) => {
|
||||
const {
|
||||
series,
|
||||
timeRange,
|
||||
width,
|
||||
height,
|
||||
showBars,
|
||||
showLines,
|
||||
showPoints,
|
||||
sortLegendBy,
|
||||
sortLegendDesc,
|
||||
isLegendVisible,
|
||||
displayMode,
|
||||
placement,
|
||||
onSeriesAxisToggle,
|
||||
onSeriesColorChange,
|
||||
onSeriesToggle,
|
||||
onToggleSort,
|
||||
hideEmpty,
|
||||
hideZero,
|
||||
} = props;
|
||||
const { graphContainer, wrapper, legendContainer } = getGraphWithLegendStyles(props);
|
||||
|
||||
const legendItems = series.reduce<LegendItem[]>((acc, s) => {
|
||||
return shouldHideLegendItem(s.data, hideEmpty, hideZero)
|
||||
? acc
|
||||
: acc.concat([
|
||||
{
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
isVisible: s.isVisible,
|
||||
yAxis: s.yAxis,
|
||||
displayValues: s.info || [],
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={wrapper}>
|
||||
<div className={graphContainer}>
|
||||
<Graph
|
||||
series={series.filter(s => !!s.isVisible)}
|
||||
timeRange={timeRange}
|
||||
showLines={showLines}
|
||||
showPoints={showPoints}
|
||||
showBars={showBars}
|
||||
width={width}
|
||||
height={height}
|
||||
key={isLegendVisible ? 'legend-visible' : 'legend-invisible'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLegendVisible && (
|
||||
<div className={legendContainer}>
|
||||
<CustomScrollbar hideHorizontalTrack>
|
||||
<GraphLegend
|
||||
items={legendItems}
|
||||
displayMode={displayMode}
|
||||
placement={placement}
|
||||
sortBy={sortLegendBy}
|
||||
sortDesc={sortLegendDesc}
|
||||
onLabelClick={(item, event) => {
|
||||
if (onSeriesToggle) {
|
||||
onSeriesToggle(item.label, event);
|
||||
}
|
||||
}}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
onSeriesAxisToggle={onSeriesAxisToggle}
|
||||
onToggleSort={onToggleSort}
|
||||
/>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
3316
packages/grafana-ui/src/components/Graph/mockGraphWithLegendData.ts
Normal file
3316
packages/grafana-ui/src/components/Graph/mockGraphWithLegendData.ts
Normal file
File diff suppressed because it is too large
Load Diff
142
packages/grafana-ui/src/components/Legend/Legend.story.tsx
Normal file
142
packages/grafana-ui/src/components/Legend/Legend.story.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { LegendList, LegendPlacement, LegendItem, LegendTable } from './Legend';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { DisplayValue } from '../../types/index';
|
||||
import { number, select, text } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { GraphLegendListItem, GraphLegendTableRow, GraphLegendItemProps } from '../Graph/GraphLegendItem';
|
||||
|
||||
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(),
|
||||
isVisible: true,
|
||||
yAxis: 1,
|
||||
displayValues: statsToDisplay || [],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
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>, isVisible:{' '}
|
||||
<strong>{item.isVisible ? 'yes' : 'no'}</strong>
|
||||
</>
|
||||
);
|
||||
|
||||
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-tabe',
|
||||
}
|
||||
: {
|
||||
'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',
|
||||
{
|
||||
under: 'under',
|
||||
right: 'right',
|
||||
},
|
||||
'under'
|
||||
);
|
||||
|
||||
return {
|
||||
numberOfSeries,
|
||||
containerWidth,
|
||||
itemRenderer:
|
||||
legendItemRenderer === 'raw'
|
||||
? rawRenderer
|
||||
: customRenderer(legendItemRenderer === 'custom-list' ? GraphLegendListItem : GraphLegendTableRow),
|
||||
rightAxisSeries,
|
||||
legendPlacement,
|
||||
};
|
||||
};
|
||||
|
||||
const LegendStories = storiesOf('UI/Legend/Legend', module);
|
||||
|
||||
LegendStories.add('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>
|
||||
);
|
||||
});
|
||||
|
||||
LegendStories.add('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>
|
||||
);
|
||||
});
|
43
packages/grafana-ui/src/components/Legend/Legend.tsx
Normal file
43
packages/grafana-ui/src/components/Legend/Legend.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { DisplayValue } from '../../types/index';
|
||||
|
||||
import { LegendList } from './LegendList';
|
||||
import { LegendTable } from './LegendTable';
|
||||
|
||||
export enum LegendDisplayMode {
|
||||
List = 'list',
|
||||
Table = 'table',
|
||||
}
|
||||
export interface LegendBasicOptions {
|
||||
isVisible: boolean;
|
||||
asTable: boolean;
|
||||
}
|
||||
|
||||
export interface LegendRenderOptions {
|
||||
placement: LegendPlacement;
|
||||
hideEmpty?: boolean;
|
||||
hideZero?: boolean;
|
||||
}
|
||||
|
||||
export type LegendPlacement = 'under' | 'right' | 'over'; // Over used by piechart
|
||||
|
||||
export interface LegendOptions extends LegendBasicOptions, LegendRenderOptions {}
|
||||
|
||||
export interface LegendItem {
|
||||
label: string;
|
||||
color: string;
|
||||
isVisible: boolean;
|
||||
yAxis: number;
|
||||
displayValues?: DisplayValue[];
|
||||
}
|
||||
|
||||
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 };
|
66
packages/grafana-ui/src/components/Legend/LegendList.tsx
Normal file
66
packages/grafana-ui/src/components/Legend/LegendList.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
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';
|
||||
|
||||
export const LegendList: React.FunctionComponent<LegendComponentProps> = ({
|
||||
items,
|
||||
itemRenderer,
|
||||
placement,
|
||||
className,
|
||||
}) => {
|
||||
const theme = useContext(ThemeContext);
|
||||
|
||||
const renderItem = (item: LegendItem, index: number) => {
|
||||
return (
|
||||
<span
|
||||
className={css`
|
||||
padding-left: 10px;
|
||||
display: flex;
|
||||
font-size: ${theme.typography.size.sm};
|
||||
white-space: nowrap;
|
||||
`}
|
||||
>
|
||||
{itemRenderer ? itemRenderer(item, index) : item.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const getItemKey = (item: LegendItem) => item.label;
|
||||
|
||||
const styles = {
|
||||
wrapper: cx(
|
||||
css`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
`,
|
||||
className
|
||||
),
|
||||
section: css`
|
||||
display: flex;
|
||||
`,
|
||||
sectionRight: css`
|
||||
justify-content: flex-end;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
};
|
||||
|
||||
return placement === 'under' ? (
|
||||
<div className={styles.wrapper}>
|
||||
<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';
|
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { SeriesColorPicker } from '../ColorPicker/ColorPicker';
|
||||
import { SeriesIcon } from './SeriesIcon';
|
||||
|
||||
interface LegendSeriesIconProps {
|
||||
color: string;
|
||||
yAxis: number;
|
||||
onColorChange: (color: string) => void;
|
||||
onToggleAxis?: () => void;
|
||||
}
|
||||
|
||||
export const LegendSeriesIcon: React.FunctionComponent<LegendSeriesIconProps> = ({
|
||||
yAxis,
|
||||
color,
|
||||
onColorChange,
|
||||
onToggleAxis,
|
||||
}) => {
|
||||
return (
|
||||
<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 color={color} />
|
||||
</span>
|
||||
)}
|
||||
</SeriesColorPicker>
|
||||
);
|
||||
};
|
||||
|
||||
LegendSeriesIcon.displayName = 'LegendSeriesIcon';
|
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { InlineList } from '../List/InlineList';
|
||||
import { css } from 'emotion';
|
||||
import { DisplayValue } from '../../types/displayValue';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
|
||||
const LegendItemStat: React.FunctionComponent<{ stat: DisplayValue }> = ({ stat }) => {
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
margin-left: 6px;
|
||||
`}
|
||||
>
|
||||
{stat.title && `${capitalize(stat.title)}:`} {stat.text}
|
||||
</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';
|
83
packages/grafana-ui/src/components/Legend/LegendTable.tsx
Normal file
83
packages/grafana-ui/src/components/Legend/LegendTable.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { LegendComponentProps } from './Legend';
|
||||
import { ThemeContext } from '../../themes/ThemeContext';
|
||||
|
||||
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.blue};
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
`}
|
||||
onClick={() => {
|
||||
if (onToggleSort) {
|
||||
onToggleSort(columnHeader);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columnHeader}
|
||||
{sortBy === columnHeader && (
|
||||
<span
|
||||
className={cx(
|
||||
`fa fa-caret-${sortDesc ? 'down' : 'up'}`,
|
||||
css`
|
||||
margin-left: ${theme.spacing.sm};
|
||||
`
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
5
packages/grafana-ui/src/components/Legend/SeriesIcon.tsx
Normal file
5
packages/grafana-ui/src/components/Legend/SeriesIcon.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export const SeriesIcon: React.FunctionComponent<{ color: string }> = ({ color }) => {
|
||||
return <i className="fa fa-minus pointer" style={{ color }} />;
|
||||
};
|
@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { AbstractList } from './AbstractList';
|
||||
|
||||
describe('AbstractList', () => {
|
||||
it('renders items using renderItem prop function', () => {
|
||||
const items = [{ name: 'Item 1', id: 'item1' }, { name: 'Item 2', id: 'item2' }, { name: 'Item 3', id: 'item3' }];
|
||||
|
||||
const list = shallow(
|
||||
<AbstractList
|
||||
items={items}
|
||||
renderItem={item => (
|
||||
<div>
|
||||
<h1>{item.name}</h1>
|
||||
<small>{item.id}</small>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(list).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('allows custom item key', () => {
|
||||
const items = [{ name: 'Item 1', id: 'item1' }, { name: 'Item 2', id: 'item2' }, { name: 'Item 3', id: 'item3' }];
|
||||
|
||||
const list = shallow(
|
||||
<AbstractList
|
||||
items={items}
|
||||
getItemKey={item => item.id}
|
||||
renderItem={item => (
|
||||
<div>
|
||||
<h1>{item.name}</h1>
|
||||
<small>{item.id}</small>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(list).toMatchSnapshot();
|
||||
});
|
||||
});
|
53
packages/grafana-ui/src/components/List/AbstractList.tsx
Normal file
53
packages/grafana-ui/src/components/List/AbstractList.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { cx, css } from 'emotion';
|
||||
|
||||
export interface ListProps<T> {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => JSX.Element;
|
||||
getItemKey?: (item: T) => string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface AbstractListProps<T> extends ListProps<T> {
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export class AbstractList<T> extends React.PureComponent<AbstractListProps<T>> {
|
||||
constructor(props: AbstractListProps<T>) {
|
||||
super(props);
|
||||
this.getListStyles = this.getListStyles.bind(this);
|
||||
}
|
||||
|
||||
getListStyles() {
|
||||
const { inline, className } = this.props;
|
||||
return {
|
||||
list: cx([
|
||||
css`
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`,
|
||||
className,
|
||||
]),
|
||||
item: css`
|
||||
display: ${(inline && 'inline-block') || 'block'};
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
const { items, renderItem, getItemKey } = this.props;
|
||||
const styles = this.getListStyles();
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{items.map((item, i) => {
|
||||
return (
|
||||
<li className={styles.item} key={getItemKey ? getItemKey(item) : i}>
|
||||
{renderItem(item, i)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
8
packages/grafana-ui/src/components/List/InlineList.tsx
Normal file
8
packages/grafana-ui/src/components/List/InlineList.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { ListProps, AbstractList } from './AbstractList';
|
||||
|
||||
export class InlineList<T> extends React.PureComponent<ListProps<T>> {
|
||||
render() {
|
||||
return <AbstractList inline {...this.props} />;
|
||||
}
|
||||
}
|
68
packages/grafana-ui/src/components/List/List.story.tsx
Normal file
68
packages/grafana-ui/src/components/List/List.story.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { number, select } from '@storybook/addon-knobs';
|
||||
import { List } from './List';
|
||||
import { css, cx } from 'emotion';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { InlineList } from './InlineList';
|
||||
|
||||
const ListStories = storiesOf('UI/List', module);
|
||||
|
||||
const generateListItems = (numberOfItems: number) => {
|
||||
return [...new Array(numberOfItems)].map((item, i) => {
|
||||
return {
|
||||
name: `Item-${i}`,
|
||||
id: `item-${i}`,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getStoriesKnobs = (inline = false) => {
|
||||
const numberOfItems = number('Number of items', 3);
|
||||
const rawRenderer = (item: any) => <>{item.name}</>;
|
||||
const customRenderer = (item: any, index: number) => (
|
||||
<div
|
||||
className={cx([
|
||||
css`
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
background: ${tinycolor.fromRatio({ h: index / 26, s: 1, v: 1 }).toHexString()};
|
||||
padding: 10px;
|
||||
`,
|
||||
inline
|
||||
? css`
|
||||
margin-right: 20px;
|
||||
`
|
||||
: css`
|
||||
margin-bottom: 20px;
|
||||
`,
|
||||
])}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
);
|
||||
|
||||
const itemRenderer = select(
|
||||
'Item rendered',
|
||||
{
|
||||
'Raw renderer': 'raw',
|
||||
'Custom renderer': 'custom',
|
||||
},
|
||||
'raw'
|
||||
);
|
||||
|
||||
return {
|
||||
numberOfItems,
|
||||
renderItem: itemRenderer === 'raw' ? rawRenderer : customRenderer,
|
||||
};
|
||||
};
|
||||
|
||||
ListStories.add('default', () => {
|
||||
const { numberOfItems, renderItem } = getStoriesKnobs();
|
||||
return <List items={generateListItems(numberOfItems)} renderItem={renderItem} />;
|
||||
});
|
||||
|
||||
ListStories.add('inline', () => {
|
||||
const { numberOfItems, renderItem } = getStoriesKnobs(true);
|
||||
return <InlineList items={generateListItems(numberOfItems)} renderItem={renderItem} />;
|
||||
});
|
8
packages/grafana-ui/src/components/List/List.tsx
Normal file
8
packages/grafana-ui/src/components/List/List.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { ListProps, AbstractList } from './AbstractList';
|
||||
|
||||
export class List<T> extends React.PureComponent<ListProps<T>> {
|
||||
render() {
|
||||
return <AbstractList {...this.props} />;
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AbstractList allows custom item key 1`] = `
|
||||
<ul
|
||||
className="css-9xf0yn"
|
||||
>
|
||||
<li
|
||||
className="css-rwbibe"
|
||||
key="item1"
|
||||
>
|
||||
<div>
|
||||
<h1>
|
||||
Item 1
|
||||
</h1>
|
||||
<small>
|
||||
item1
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
className="css-rwbibe"
|
||||
key="item2"
|
||||
>
|
||||
<div>
|
||||
<h1>
|
||||
Item 2
|
||||
</h1>
|
||||
<small>
|
||||
item2
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
className="css-rwbibe"
|
||||
key="item3"
|
||||
>
|
||||
<div>
|
||||
<h1>
|
||||
Item 3
|
||||
</h1>
|
||||
<small>
|
||||
item3
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
|
||||
exports[`AbstractList renders items using renderItem prop function 1`] = `
|
||||
<ul
|
||||
className="css-9xf0yn"
|
||||
>
|
||||
<li
|
||||
className="css-rwbibe"
|
||||
key="0"
|
||||
>
|
||||
<div>
|
||||
<h1>
|
||||
Item 1
|
||||
</h1>
|
||||
<small>
|
||||
item1
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
className="css-rwbibe"
|
||||
key="1"
|
||||
>
|
||||
<div>
|
||||
<h1>
|
||||
Item 2
|
||||
</h1>
|
||||
<small>
|
||||
item2
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
className="css-rwbibe"
|
||||
key="2"
|
||||
>
|
||||
<div>
|
||||
<h1>
|
||||
Item 3
|
||||
</h1>
|
||||
<small>
|
||||
item3
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
@ -22,7 +22,7 @@ export { SecretFormField } from './SecretFormFied/SecretFormField';
|
||||
export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
|
||||
export { ColorPicker, SeriesColorPicker } from './ColorPicker/ColorPicker';
|
||||
export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './ColorPicker/SeriesColorPickerPopover';
|
||||
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
||||
|
||||
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
||||
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
|
||||
export { ValueMappingsEditor } from './ValueMappingsEditor/ValueMappingsEditor';
|
||||
@ -44,8 +44,12 @@ export { TableInputCSV } from './Table/TableInputCSV';
|
||||
export { BigValue } from './BigValue/BigValue';
|
||||
export { Gauge } from './Gauge/Gauge';
|
||||
export { Graph } from './Graph/Graph';
|
||||
export { GraphWithLegend } from './Graph/GraphWithLegend';
|
||||
export { BarGauge } from './BarGauge/BarGauge';
|
||||
export { VizRepeater } from './VizRepeater/VizRepeater';
|
||||
export { LegendOptions, LegendBasicOptions, LegendRenderOptions, LegendList, LegendTable } from './Legend/Legend';
|
||||
// Panel editors
|
||||
export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
|
||||
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
|
||||
export * from './SingleStatShared/shared';
|
||||
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
|
||||
|
@ -8,4 +8,6 @@ export interface GraphSeriesXY {
|
||||
color: string;
|
||||
data: GraphSeriesValue[][]; // [x,y][]
|
||||
info?: DisplayValue[]; // Legend info
|
||||
isVisible: boolean;
|
||||
yAxis: number;
|
||||
}
|
||||
|
@ -9,3 +9,4 @@ export * from './threshold';
|
||||
export * from './input';
|
||||
export * from './logs';
|
||||
export * from './displayValue';
|
||||
export * from './utils';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ComponentClass } from 'react';
|
||||
import { ComponentClass, ComponentType } from 'react';
|
||||
import { LoadingState, SeriesData } from './data';
|
||||
import { TimeRange } from './time';
|
||||
import { ScopedVars, DataQueryRequest, DataQueryError, LegacyResponseData } from './datasource';
|
||||
@ -21,6 +21,7 @@ export interface PanelProps<T = any> {
|
||||
|
||||
timeRange: TimeRange;
|
||||
options: T;
|
||||
onOptionsChange: (options: T) => void;
|
||||
renderCounter: number;
|
||||
width: number;
|
||||
height: number;
|
||||
@ -53,13 +54,13 @@ export type PanelTypeChangedHandler<TOptions = any> = (
|
||||
) => Partial<TOptions>;
|
||||
|
||||
export class ReactPanelPlugin<TOptions = any> {
|
||||
panel: ComponentClass<PanelProps<TOptions>>;
|
||||
panel: ComponentType<PanelProps<TOptions>>;
|
||||
editor?: ComponentClass<PanelEditorProps<TOptions>>;
|
||||
defaults?: TOptions;
|
||||
onPanelMigration?: PanelMigrationHandler<TOptions>;
|
||||
onPanelTypeChanged?: PanelTypeChangedHandler<TOptions>;
|
||||
|
||||
constructor(panel: ComponentClass<PanelProps<TOptions>>) {
|
||||
constructor(panel: ComponentType<PanelProps<TOptions>>) {
|
||||
this.panel = panel;
|
||||
}
|
||||
|
||||
|
2
packages/grafana-ui/src/types/utils.ts
Normal file
2
packages/grafana-ui/src/types/utils.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
export type Subtract<T, K> = Omit<T, keyof K>;
|
@ -12,4 +12,5 @@ export * from './logs';
|
||||
export * from './labels';
|
||||
export { getMappedValue } from './valueMappings';
|
||||
export * from './validate';
|
||||
export { getFlotPairs } from './flotPairs';
|
||||
export * from './object';
|
||||
|
@ -28,7 +28,6 @@ export class UseState<T> extends React.Component<StateHolderProps<T>, { value: T
|
||||
}
|
||||
|
||||
handleStateUpdate = (nextState: T) => {
|
||||
console.log(nextState);
|
||||
this.setState({ value: nextState });
|
||||
};
|
||||
|
||||
|
@ -212,7 +212,7 @@ exports[`ServerStats Should render table with stats 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="track-horizontal"
|
||||
className="css-17l4171 track-horizontal"
|
||||
style={
|
||||
Object {
|
||||
"display": "none",
|
||||
@ -233,7 +233,7 @@ exports[`ServerStats Should render table with stats 1`] = `
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="track-vertical"
|
||||
className="css-17l4171 track-vertical"
|
||||
style={
|
||||
Object {
|
||||
"display": "none",
|
||||
|
@ -167,6 +167,10 @@ export class PanelChrome extends PureComponent<Props, State> {
|
||||
});
|
||||
};
|
||||
|
||||
onOptionsChange = (options: any) => {
|
||||
this.props.panel.updateOptions(options);
|
||||
};
|
||||
|
||||
replaceVariables = (value: string, extraVars?: ScopedVars, format?: string) => {
|
||||
let vars = this.props.panel.scopedVars;
|
||||
if (extraVars) {
|
||||
@ -223,6 +227,7 @@ export class PanelChrome extends PureComponent<Props, State> {
|
||||
height={height - PANEL_HEADER_HEIGHT - config.theme.panelPadding.vertical}
|
||||
renderCounter={renderCounter}
|
||||
replaceVariables={this.replaceVariables}
|
||||
onOptionsChange={this.onOptionsChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
@ -311,7 +311,7 @@ class LegendTableHeaderItem extends PureComponent<LegendTableHeaderProps & Legen
|
||||
export class Legend extends PureComponent<GraphLegendProps> {
|
||||
render() {
|
||||
return (
|
||||
<CustomScrollbar renderTrackHorizontal={props => <div {...props} style={{ visibility: 'none' }} />}>
|
||||
<CustomScrollbar hideHorizontalTrack>
|
||||
<GraphLegend {...this.props} />
|
||||
</CustomScrollbar>
|
||||
);
|
||||
|
103
public/app/plugins/panel/graph2/GraphLegendEditor.tsx
Normal file
103
public/app/plugins/panel/graph2/GraphLegendEditor.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import without from 'lodash/without';
|
||||
import { LegendOptions, PanelOptionsGroup, Switch, Input } from '@grafana/ui';
|
||||
|
||||
export interface GraphLegendEditorLegendOptions extends LegendOptions {
|
||||
stats?: string[];
|
||||
decimals?: number;
|
||||
sortBy?: string;
|
||||
sortDesc?: boolean;
|
||||
}
|
||||
|
||||
interface GraphLegendEditorProps {
|
||||
stats: string[];
|
||||
options: GraphLegendEditorLegendOptions;
|
||||
onChange: (options: GraphLegendEditorLegendOptions) => void;
|
||||
}
|
||||
|
||||
export const GraphLegendEditor: React.FunctionComponent<GraphLegendEditorProps> = props => {
|
||||
const { stats, options, onChange } = props;
|
||||
|
||||
const onStatToggle = (stat: string) => (event?: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
let newStats;
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (event.target.checked) {
|
||||
newStats = (options.stats || []).concat([stat]);
|
||||
} else {
|
||||
newStats = without(options.stats, stat);
|
||||
}
|
||||
onChange({
|
||||
...options,
|
||||
stats: newStats,
|
||||
});
|
||||
};
|
||||
|
||||
const onOptionToggle = (option: keyof LegendOptions) => (event?: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
const newOption = {};
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
// TODO: fix the ignores
|
||||
// @ts-ignore
|
||||
newOption[option] = event.target.checked;
|
||||
if (option === 'placement') {
|
||||
// @ts-ignore
|
||||
newOption[option] = event.target.checked ? 'right' : 'under';
|
||||
}
|
||||
|
||||
onChange({
|
||||
...options,
|
||||
...newOption,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelOptionsGroup title="Legend">
|
||||
<div className="section gf-form-group">
|
||||
<h4>Options</h4>
|
||||
<Switch label="Show legend" checked={options.isVisible} onChange={onOptionToggle('isVisible')} />
|
||||
<Switch label="Display as table" checked={options.asTable} onChange={onOptionToggle('asTable')} />
|
||||
<Switch label="To the right" checked={options.placement === 'right'} onChange={onOptionToggle('placement')} />
|
||||
</div>
|
||||
<div className="section gf-form-group">
|
||||
<h4>Values</h4>
|
||||
{stats.map(stat => {
|
||||
return (
|
||||
<Switch
|
||||
label={capitalize(stat)}
|
||||
checked={!!options.stats && options.stats.indexOf(stat) > -1}
|
||||
onChange={onStatToggle(stat)}
|
||||
key={stat}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="gf-form">
|
||||
<div className="gf-form-label">Decimals</div>
|
||||
<Input
|
||||
className="gf-form-input width-5"
|
||||
type="number"
|
||||
value={options.decimals}
|
||||
placeholder="Auto"
|
||||
onChange={event => {
|
||||
onChange({
|
||||
...options,
|
||||
decimals: parseInt(event.target.value, 10),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section gf-form-group">
|
||||
<h4>Hidden series</h4>
|
||||
{/* <Switch label="With only nulls" checked={!!options.hideEmpty} onChange={onOptionToggle('hideEmpty')} /> */}
|
||||
<Switch label="With only zeros" checked={!!options.hideZero} onChange={onOptionToggle('hideZero')} />
|
||||
</div>
|
||||
</PanelOptionsGroup>
|
||||
);
|
||||
};
|
@ -1,67 +1,57 @@
|
||||
// Libraries
|
||||
import _ from 'lodash';
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { Graph, PanelProps, NullValueMode, colors, GraphSeriesXY, FieldType, getFirstTimeField } from '@grafana/ui';
|
||||
import React from 'react';
|
||||
import { PanelProps, GraphWithLegend /*, GraphSeriesXY*/ } from '@grafana/ui';
|
||||
import { Options } from './types';
|
||||
import { getFlotPairs } from '@grafana/ui/src/utils/flotPairs';
|
||||
import { GraphPanelController } from './GraphPanelController';
|
||||
import { LegendDisplayMode } from '@grafana/ui/src/components/Legend/Legend';
|
||||
|
||||
interface Props extends PanelProps<Options> {}
|
||||
|
||||
export class GraphPanel extends PureComponent<Props> {
|
||||
render() {
|
||||
const { data, timeRange, width, height } = this.props;
|
||||
const { showLines, showBars, showPoints } = this.props.options;
|
||||
|
||||
const graphs: GraphSeriesXY[] = [];
|
||||
for (const series of data.series) {
|
||||
const timeColumn = getFirstTimeField(series);
|
||||
if (timeColumn < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let i = 0; i < series.fields.length; i++) {
|
||||
const field = series.fields[i];
|
||||
|
||||
// Show all numeric columns
|
||||
if (field.type === FieldType.number) {
|
||||
// Use external calculator just to make sure it works :)
|
||||
const points = getFlotPairs({
|
||||
series,
|
||||
xIndex: timeColumn,
|
||||
yIndex: i,
|
||||
nullValueMode: NullValueMode.Null,
|
||||
});
|
||||
|
||||
if (points.length > 0) {
|
||||
graphs.push({
|
||||
label: field.name,
|
||||
data: points,
|
||||
color: colors[graphs.length % colors.length],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (graphs.length < 1) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface GraphPanelProps extends PanelProps<Options> {}
|
||||
|
||||
export const GraphPanel: React.FunctionComponent<GraphPanelProps> = ({
|
||||
data,
|
||||
timeRange,
|
||||
width,
|
||||
height,
|
||||
options,
|
||||
onOptionsChange,
|
||||
}) => {
|
||||
if (!data) {
|
||||
return (
|
||||
<Graph
|
||||
series={graphs}
|
||||
timeRange={timeRange}
|
||||
showLines={showLines}
|
||||
showPoints={showPoints}
|
||||
showBars={showBars}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
graph: { showLines, showBars, showPoints },
|
||||
legend: legendOptions,
|
||||
} = options;
|
||||
|
||||
const graphProps = {
|
||||
showBars,
|
||||
showLines,
|
||||
showPoints,
|
||||
};
|
||||
const { asTable, isVisible, ...legendProps } = legendOptions;
|
||||
return (
|
||||
<GraphPanelController data={data} options={options} onOptionsChange={onOptionsChange}>
|
||||
{({ onSeriesToggle, ...controllerApi }) => {
|
||||
return (
|
||||
<GraphWithLegend
|
||||
timeRange={timeRange}
|
||||
width={width}
|
||||
height={height}
|
||||
displayMode={asTable ? LegendDisplayMode.Table : LegendDisplayMode.List}
|
||||
isLegendVisible={isVisible}
|
||||
sortLegendBy={legendOptions.sortBy}
|
||||
sortLegendDesc={legendOptions.sortDesc}
|
||||
onSeriesToggle={onSeriesToggle}
|
||||
{...graphProps}
|
||||
{...legendProps}
|
||||
{...controllerApi}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</GraphPanelController>
|
||||
);
|
||||
};
|
||||
|
156
public/app/plugins/panel/graph2/GraphPanelController.tsx
Normal file
156
public/app/plugins/panel/graph2/GraphPanelController.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { GraphSeriesXY, PanelData } from '@grafana/ui';
|
||||
import difference from 'lodash/difference';
|
||||
import { getGraphSeriesModel } from './getGraphSeriesModel';
|
||||
import { Options, SeriesOptions } from './types';
|
||||
import { SeriesColorChangeHandler, SeriesAxisToggleHandler } from '@grafana/ui/src/components/Graph/GraphWithLegend';
|
||||
|
||||
interface GraphPanelControllerAPI {
|
||||
series: GraphSeriesXY[];
|
||||
onSeriesAxisToggle: SeriesAxisToggleHandler;
|
||||
onSeriesColorChange: SeriesColorChangeHandler;
|
||||
onSeriesToggle: (label: string, event: React.MouseEvent<HTMLElement>) => void;
|
||||
onToggleSort: (sortBy: string) => void;
|
||||
}
|
||||
|
||||
interface GraphPanelControllerProps {
|
||||
children: (api: GraphPanelControllerAPI) => JSX.Element;
|
||||
options: Options;
|
||||
data: PanelData;
|
||||
onOptionsChange: (options: Options) => void;
|
||||
}
|
||||
|
||||
interface GraphPanelControllerState {
|
||||
graphSeriesModel: GraphSeriesXY[];
|
||||
hiddenSeries: string[];
|
||||
}
|
||||
|
||||
export class GraphPanelController extends React.Component<GraphPanelControllerProps, GraphPanelControllerState> {
|
||||
constructor(props: GraphPanelControllerProps) {
|
||||
super(props);
|
||||
|
||||
this.onSeriesToggle = this.onSeriesToggle.bind(this);
|
||||
this.onSeriesColorChange = this.onSeriesColorChange.bind(this);
|
||||
this.onSeriesAxisToggle = this.onSeriesAxisToggle.bind(this);
|
||||
this.onToggleSort = this.onToggleSort.bind(this);
|
||||
|
||||
this.state = {
|
||||
graphSeriesModel: getGraphSeriesModel(
|
||||
props.data,
|
||||
props.options.series,
|
||||
props.options.graph,
|
||||
props.options.legend
|
||||
),
|
||||
hiddenSeries: [],
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: GraphPanelControllerProps, state: GraphPanelControllerState) {
|
||||
return {
|
||||
...state,
|
||||
graphSeriesModel: getGraphSeriesModel(
|
||||
props.data,
|
||||
props.options.series,
|
||||
props.options.graph,
|
||||
props.options.legend
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
onSeriesOptionsUpdate(label: string, optionsUpdate: SeriesOptions) {
|
||||
const { onOptionsChange, options } = this.props;
|
||||
const updatedSeriesOptions: { [label: string]: SeriesOptions } = { ...options.series };
|
||||
updatedSeriesOptions[label] = optionsUpdate;
|
||||
onOptionsChange({
|
||||
...options,
|
||||
series: updatedSeriesOptions,
|
||||
});
|
||||
}
|
||||
|
||||
onSeriesAxisToggle(label: string, yAxis: number) {
|
||||
const {
|
||||
options: { series },
|
||||
} = this.props;
|
||||
const seriesOptionsUpdate: SeriesOptions = series[label]
|
||||
? {
|
||||
...series[label],
|
||||
yAxis,
|
||||
}
|
||||
: {
|
||||
yAxis,
|
||||
};
|
||||
this.onSeriesOptionsUpdate(label, seriesOptionsUpdate);
|
||||
}
|
||||
|
||||
onSeriesColorChange(label: string, color: string) {
|
||||
const {
|
||||
options: { series },
|
||||
} = this.props;
|
||||
const seriesOptionsUpdate: SeriesOptions = series[label]
|
||||
? {
|
||||
...series[label],
|
||||
color,
|
||||
}
|
||||
: {
|
||||
color,
|
||||
};
|
||||
|
||||
this.onSeriesOptionsUpdate(label, seriesOptionsUpdate);
|
||||
}
|
||||
|
||||
onToggleSort(sortBy: string) {
|
||||
const { onOptionsChange, options } = this.props;
|
||||
onOptionsChange({
|
||||
...options,
|
||||
legend: {
|
||||
...options.legend,
|
||||
sortBy,
|
||||
sortDesc: sortBy === options.legend.sortBy ? !options.legend.sortDesc : false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onSeriesToggle(label: string, event: React.MouseEvent<HTMLElement>) {
|
||||
const { hiddenSeries, graphSeriesModel } = this.state;
|
||||
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||
// Toggling series with key makes the series itself to toggle
|
||||
if (hiddenSeries.indexOf(label) > -1) {
|
||||
this.setState({
|
||||
hiddenSeries: hiddenSeries.filter(series => series !== label),
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
hiddenSeries: hiddenSeries.concat([label]),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Toggling series with out key toggles all the series but the clicked one
|
||||
const allSeriesLabels = graphSeriesModel.map(series => series.label);
|
||||
|
||||
if (hiddenSeries.length + 1 === allSeriesLabels.length) {
|
||||
this.setState({ hiddenSeries: [] });
|
||||
} else {
|
||||
this.setState({
|
||||
hiddenSeries: difference(allSeriesLabels, [label]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
const { graphSeriesModel, hiddenSeries } = this.state;
|
||||
|
||||
return children({
|
||||
series: graphSeriesModel.map(series => ({
|
||||
...series,
|
||||
isVisible: hiddenSeries.indexOf(series.label) === -1,
|
||||
})),
|
||||
onSeriesToggle: this.onSeriesToggle,
|
||||
onSeriesColorChange: this.onSeriesColorChange,
|
||||
onSeriesAxisToggle: this.onSeriesAxisToggle,
|
||||
onToggleSort: this.onToggleSort,
|
||||
});
|
||||
}
|
||||
}
|
@ -3,40 +3,56 @@ import _ from 'lodash';
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Types
|
||||
import { PanelEditorProps, Switch } from '@grafana/ui';
|
||||
import { Options } from './types';
|
||||
import { PanelEditorProps, Switch, LegendOptions, StatID } from '@grafana/ui';
|
||||
import { Options, GraphOptions } from './types';
|
||||
import { GraphLegendEditor } from './GraphLegendEditor';
|
||||
|
||||
export class GraphPanelEditor extends PureComponent<PanelEditorProps<Options>> {
|
||||
onGraphOptionsChange = (options: Partial<GraphOptions>) => {
|
||||
this.props.onOptionsChange({
|
||||
...this.props.options,
|
||||
graph: {
|
||||
...this.props.options.graph,
|
||||
...options,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onLegendOptionsChange = (options: LegendOptions) => {
|
||||
this.props.onOptionsChange({ ...this.props.options, legend: options });
|
||||
};
|
||||
|
||||
onToggleLines = () => {
|
||||
this.props.onOptionsChange({ ...this.props.options, showLines: !this.props.options.showLines });
|
||||
this.onGraphOptionsChange({ showLines: !this.props.options.graph.showLines });
|
||||
};
|
||||
|
||||
onToggleBars = () => {
|
||||
this.props.onOptionsChange({ ...this.props.options, showBars: !this.props.options.showBars });
|
||||
this.onGraphOptionsChange({ showBars: !this.props.options.graph.showBars });
|
||||
};
|
||||
|
||||
onTogglePoints = () => {
|
||||
this.props.onOptionsChange({ ...this.props.options, showPoints: !this.props.options.showPoints });
|
||||
this.onGraphOptionsChange({ showPoints: !this.props.options.graph.showPoints });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showBars, showPoints, showLines } = this.props.options;
|
||||
const {
|
||||
graph: { showBars, showPoints, showLines },
|
||||
} = this.props.options;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<div className="section gf-form-group">
|
||||
<h5 className="section-heading">Draw Modes</h5>
|
||||
<Switch label="Lines" labelClass="width-5" checked={showLines} onChange={this.onToggleLines} />
|
||||
<Switch label="Bars" labelClass="width-5" checked={showBars} onChange={this.onToggleBars} />
|
||||
<Switch label="Points" labelClass="width-5" checked={showPoints} onChange={this.onTogglePoints} />
|
||||
</div>
|
||||
<div className="section gf-form-group">
|
||||
<h5 className="section-heading">Test Options</h5>
|
||||
<Switch label="Lines" labelClass="width-5" checked={showLines} onChange={this.onToggleLines} />
|
||||
<Switch label="Bars" labelClass="width-5" checked={showBars} onChange={this.onToggleBars} />
|
||||
<Switch label="Points" labelClass="width-5" checked={showPoints} onChange={this.onTogglePoints} />
|
||||
</div>
|
||||
</div>
|
||||
<GraphLegendEditor
|
||||
stats={[StatID.min, StatID.max, StatID.mean, StatID.last, StatID.sum]}
|
||||
options={this.props.options.legend}
|
||||
onChange={this.onLegendOptionsChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
83
public/app/plugins/panel/graph2/getGraphSeriesModel.ts
Normal file
83
public/app/plugins/panel/graph2/getGraphSeriesModel.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import {
|
||||
GraphSeriesXY,
|
||||
getFirstTimeField,
|
||||
FieldType,
|
||||
NullValueMode,
|
||||
calculateStats,
|
||||
colors,
|
||||
getFlotPairs,
|
||||
getColorFromHexRgbOrName,
|
||||
getDisplayProcessor,
|
||||
DisplayValue,
|
||||
PanelData,
|
||||
} from '@grafana/ui';
|
||||
import { SeriesOptions, GraphOptions } from './types';
|
||||
import { GraphLegendEditorLegendOptions } from './GraphLegendEditor';
|
||||
|
||||
export const getGraphSeriesModel = (
|
||||
data: PanelData,
|
||||
seriesOptions: SeriesOptions,
|
||||
graphOptions: GraphOptions,
|
||||
legendOptions: GraphLegendEditorLegendOptions
|
||||
) => {
|
||||
const graphs: GraphSeriesXY[] = [];
|
||||
|
||||
const displayProcessor = getDisplayProcessor({
|
||||
decimals: legendOptions.decimals,
|
||||
});
|
||||
|
||||
for (const series of data.series) {
|
||||
const timeColumn = getFirstTimeField(series);
|
||||
if (timeColumn < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let i = 0; i < series.fields.length; i++) {
|
||||
const field = series.fields[i];
|
||||
|
||||
// Show all numeric columns
|
||||
if (field.type === FieldType.number) {
|
||||
// Use external calculator just to make sure it works :)
|
||||
const points = getFlotPairs({
|
||||
series,
|
||||
xIndex: timeColumn,
|
||||
yIndex: i,
|
||||
nullValueMode: NullValueMode.Null,
|
||||
});
|
||||
|
||||
if (points.length > 0) {
|
||||
const seriesStats = calculateStats({ series, stats: legendOptions.stats, fieldIndex: i });
|
||||
let statsDisplayValues;
|
||||
|
||||
if (legendOptions.stats) {
|
||||
statsDisplayValues = legendOptions.stats.map<DisplayValue>(stat => {
|
||||
const statDisplayValue = displayProcessor(seriesStats[stat]);
|
||||
|
||||
return {
|
||||
...statDisplayValue,
|
||||
text: statDisplayValue.text,
|
||||
title: stat,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const seriesColor =
|
||||
seriesOptions[field.name] && seriesOptions[field.name].color
|
||||
? getColorFromHexRgbOrName(seriesOptions[field.name].color)
|
||||
: colors[graphs.length % colors.length];
|
||||
|
||||
graphs.push({
|
||||
label: field.name,
|
||||
data: points,
|
||||
color: seriesColor,
|
||||
info: statsDisplayValues,
|
||||
isVisible: true,
|
||||
yAxis: (seriesOptions[field.name] && seriesOptions[field.name].yAxis) || 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return graphs;
|
||||
};
|
@ -1,11 +1,34 @@
|
||||
export interface Options {
|
||||
import { LegendOptions } from '@grafana/ui';
|
||||
import { GraphLegendEditorLegendOptions } from './GraphLegendEditor';
|
||||
|
||||
export interface SeriesOptions {
|
||||
color?: string;
|
||||
yAxis?: number;
|
||||
}
|
||||
export interface GraphOptions {
|
||||
showBars: boolean;
|
||||
showLines: boolean;
|
||||
showPoints: boolean;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
graph: GraphOptions;
|
||||
legend: LegendOptions & GraphLegendEditorLegendOptions;
|
||||
series: {
|
||||
[alias: string]: SeriesOptions;
|
||||
};
|
||||
}
|
||||
|
||||
export const defaults: Options = {
|
||||
showBars: false,
|
||||
showLines: true,
|
||||
showPoints: false,
|
||||
graph: {
|
||||
showBars: false,
|
||||
showLines: true,
|
||||
showPoints: false,
|
||||
},
|
||||
legend: {
|
||||
asTable: false,
|
||||
isVisible: true,
|
||||
placement: 'under',
|
||||
},
|
||||
series: {},
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user