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:
Dominik Prokop 2019-04-24 10:14:18 +02:00 committed by GitHub
parent 7dadebb3f0
commit 739cdcfb6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 5068 additions and 98 deletions

View File

@ -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

View File

@ -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" />}

View File

@ -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",

View File

@ -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: [],

View 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>
);
});

View 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}
/>
)}
/>
);
};

View 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>
);
};

View File

@ -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>
);
});

View 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>
);
};

File diff suppressed because it is too large Load Diff

View 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>
);
});

View 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 };

View 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';

View File

@ -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';

View File

@ -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';

View 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>
);
};

View 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 }} />;
};

View File

@ -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();
});
});

View 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>
);
}
}

View 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} />;
}
}

View 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} />;
});

View 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} />;
}
}

View File

@ -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>
`;

View File

@ -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';

View File

@ -8,4 +8,6 @@ export interface GraphSeriesXY {
color: string;
data: GraphSeriesValue[][]; // [x,y][]
info?: DisplayValue[]; // Legend info
isVisible: boolean;
yAxis: number;
}

View File

@ -9,3 +9,4 @@ export * from './threshold';
export * from './input';
export * from './logs';
export * from './displayValue';
export * from './utils';

View File

@ -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;
}

View 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>;

View File

@ -12,4 +12,5 @@ export * from './logs';
export * from './labels';
export { getMappedValue } from './valueMappings';
export * from './validate';
export { getFlotPairs } from './flotPairs';
export * from './object';

View File

@ -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 });
};

View File

@ -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",

View File

@ -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>
</>

View File

@ -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>
);

View 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>
);
};

View File

@ -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>
);
};

View 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,
});
}
}

View File

@ -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}
/>
</>
);
}
}

View 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;
};

View File

@ -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: {},
};