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:
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user