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