[Infra UI] Add Legend Settings for Waffle Map (#32228) (#32550)

* Adding legend controls

* Adding url support and finishing feature

* Moving config to right side; setting min/max to data boundries when deactivating auto

* Removing legend from max and min labels

* removing uneseccary unshift(0)

* Change autobounds behavior
This commit is contained in:
Chris Cowan 2019-03-06 08:28:17 -07:00 committed by GitHub
parent 96197f7a87
commit db5e282d59
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 298 additions and 23 deletions

View file

@ -34,6 +34,8 @@ interface Props {
onViewChange: (view: string) => void;
view: string;
intl: InjectedIntl;
boundsOverride: InfraWaffleMapBounds;
autoBounds: boolean;
}
interface MetricFormatter {
@ -51,12 +53,10 @@ const METRIC_FORMATTERS: MetricFormatters = {
[InfraMetricType.cpu]: {
formatter: InfraFormatterType.percent,
template: '{{value}}',
bounds: { min: 0, max: 1 },
},
[InfraMetricType.memory]: {
formatter: InfraFormatterType.percent,
template: '{{value}}',
bounds: { min: 0, max: 1 },
},
[InfraMetricType.rx]: { formatter: InfraFormatterType.bits, template: '{{value}}/s' },
[InfraMetricType.tx]: { formatter: InfraFormatterType.bits, template: '{{value}}/s' },
@ -67,19 +67,33 @@ const METRIC_FORMATTERS: MetricFormatters = {
};
const calculateBoundsFromNodes = (nodes: InfraNode[]): InfraWaffleMapBounds => {
const values = nodes.map(node => node.metric.value);
// if there is only one value then we need to set the bottom range to zero
if (values.length === 1) {
values.unshift(0);
const maxValues = nodes.map(node => node.metric.max);
const minValues = nodes.map(node => node.metric.value);
// if there is only one value then we need to set the bottom range to zero for min
// otherwise the legend will look silly since both values are the same for top and
// bottom.
if (minValues.length === 1) {
minValues.unshift(0);
}
return { min: min(values) || 0, max: max(values) || 0 };
return { min: min(minValues) || 0, max: max(maxValues) || 0 };
};
export const NodesOverview = injectI18n(
class extends React.Component<Props, {}> {
public static displayName = 'Waffle';
public render() {
const { loading, nodes, nodeType, reload, intl, view, options, timeRange } = this.props;
const {
autoBounds,
boundsOverride,
loading,
nodes,
nodeType,
reload,
intl,
view,
options,
timeRange,
} = this.props;
if (loading) {
return (
<InfraLoadingPanel
@ -113,13 +127,8 @@ export const NodesOverview = injectI18n(
/>
);
}
const { metric } = this.props.options;
const metricFormatter = get(
METRIC_FORMATTERS,
metric.type,
METRIC_FORMATTERS[InfraMetricType.count]
);
const bounds = (metricFormatter && metricFormatter.bounds) || calculateBoundsFromNodes(nodes);
const dataBounds = calculateBoundsFromNodes(nodes);
const bounds = autoBounds ? dataBounds : boundsOverride;
return (
<MainContainer>
<ViewSwitcherContainer>
@ -146,6 +155,7 @@ export const NodesOverview = injectI18n(
timeRange={timeRange}
onFilter={this.handleDrilldown}
bounds={bounds}
dataBounds={dataBounds}
/>
</MapContainer>
)}

View file

@ -57,7 +57,7 @@ const GradientLegendContainer = styled.div`
height: 10px;
bottom: 0;
left: 0;
right: 0;
right: 40px;
`;
const GradientLegendTick = styled.div`

View file

@ -5,19 +5,41 @@
*/
import React from 'react';
import styled from 'styled-components';
import { WithWaffleOptions } from '../../containers/waffle/with_waffle_options';
import { InfraFormatter, InfraWaffleMapBounds, InfraWaffleMapLegend } from '../../lib/lib';
import { GradientLegend } from './gradient_legend';
import { LegendControls } from './legend_controls';
import { isInfraWaffleMapGradientLegend, isInfraWaffleMapStepLegend } from './lib/type_guards';
import { StepLegend } from './steps_legend';
interface Props {
legend: InfraWaffleMapLegend;
bounds: InfraWaffleMapBounds;
dataBounds: InfraWaffleMapBounds;
formatter: InfraFormatter;
}
export const Legend: React.SFC<Props> = ({ legend, bounds, formatter }) => {
interface LegendControlOptions {
auto: boolean;
bounds: InfraWaffleMapBounds;
}
export const Legend: React.SFC<Props> = ({ dataBounds, legend, bounds, formatter }) => {
return (
<LegendContainer>
<WithWaffleOptions>
{({ changeBoundsOverride, changeAutoBounds, autoBounds, boundsOverride }) => (
<LegendControls
dataBounds={dataBounds}
bounds={bounds}
autoBounds={autoBounds}
boundsOverride={boundsOverride}
onChange={(options: LegendControlOptions) => {
changeBoundsOverride(options.bounds);
changeAutoBounds(options.auto);
}}
/>
)}
</WithWaffleOptions>
{isInfraWaffleMapGradientLegend(legend) && (
<GradientLegend formatter={formatter} legend={legend} bounds={bounds} />
)}

View file

@ -0,0 +1,170 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiButton,
EuiButtonIcon,
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiPopover,
EuiPopoverTitle,
EuiSwitch,
EuiText,
} from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React, { SyntheticEvent, useState } from 'react';
import styled from 'styled-components';
import { InfraWaffleMapBounds } from '../../lib/lib';
interface Props {
onChange: (options: { auto: boolean; bounds: InfraWaffleMapBounds }) => void;
bounds: InfraWaffleMapBounds;
dataBounds: InfraWaffleMapBounds;
autoBounds: boolean;
boundsOverride: InfraWaffleMapBounds;
intl: InjectedIntl;
}
export const LegendControls = injectI18n(
({ intl, autoBounds, boundsOverride, onChange, dataBounds }: Props) => {
const [isPopoverOpen, setPopoverState] = useState(false);
const [draftAuto, setDraftAuto] = useState(autoBounds);
const [draftBounds, setDraftBounds] = useState(autoBounds ? dataBounds : boundsOverride); // should come from bounds prop
const buttonComponent = (
<EuiButtonIcon
iconType="gear"
color="text"
aria-label={intl.formatMessage({
id: 'xpack.infra.legendControls.buttonLabel',
defaultMessage: 'configure legend',
})}
onClick={() => setPopoverState(true)}
/>
);
const handleAutoChange = (e: SyntheticEvent<HTMLInputElement>) => {
setDraftAuto(e.currentTarget.checked);
};
const createBoundsHandler = (name: string) => (e: SyntheticEvent<HTMLInputElement>) => {
const value = parseFloat(e.currentTarget.value);
setDraftBounds({ ...draftBounds, [name]: value });
};
const handlePopoverClose = () => {
setPopoverState(false);
};
const handleApplyClick = () => {
onChange({ auto: draftAuto, bounds: draftBounds });
};
const commited =
draftAuto === autoBounds &&
boundsOverride.min === draftBounds.min &&
boundsOverride.max === draftBounds.max;
const boundsValidRange = draftBounds.min < draftBounds.max;
return (
<ControlContainer>
<EuiPopover
isOpen={isPopoverOpen}
closePopover={handlePopoverClose}
id="legendControls"
button={buttonComponent}
withTitle
>
<EuiPopoverTitle>Legend Options</EuiPopoverTitle>
<EuiForm>
<EuiFormRow>
<EuiSwitch
name="bounds"
label={intl.formatMessage({
id: 'xpack.infra.legendControls.switchLabel',
defaultMessage: 'Auto calculate range',
})}
checked={draftAuto}
onChange={handleAutoChange}
/>
</EuiFormRow>
{(!boundsValidRange && (
<EuiText color="danger" grow={false} size="s">
<p>
<FormattedMessage
id="xpack.infra.legendControls.errorMessage"
defaultMessage="Min should be less than max"
/>
</p>
</EuiText>
)) ||
null}
<EuiFlexGroup style={{ marginTop: 0 }}>
<EuiFlexItem>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.infra.legendControls.minLabel',
defaultMessage: 'Min',
})}
isInvalid={!boundsValidRange}
>
<EuiFieldNumber
disabled={draftAuto}
step={0.1}
value={isNaN(draftBounds.min) ? '' : draftBounds.min}
isInvalid={!boundsValidRange}
name="legendMin"
onChange={createBoundsHandler('min')}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.infra.legendControls.maxLabel',
defaultMessage: 'Max',
})}
isInvalid={!boundsValidRange}
>
<EuiFieldNumber
disabled={draftAuto}
step={0.1}
isInvalid={!boundsValidRange}
value={isNaN(draftBounds.max) ? '' : draftBounds.max}
name="legendMax"
onChange={createBoundsHandler('max')}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiButton
type="submit"
size="s"
fill
disabled={commited || !boundsValidRange}
onClick={handleApplyClick}
>
<FormattedMessage
id="xpack.infra.legendControls.applyButton"
defaultMessage="Apply"
/>
</EuiButton>
</EuiForm>
</EuiPopover>
</ControlContainer>
);
}
);
const ControlContainer = styled.div`
position: absolute;
top: -20px;
right: 6px;
bottom: 0;
`;

View file

@ -26,6 +26,7 @@ interface Props {
timeRange: InfraTimerangeInput;
onFilter: (filter: string) => void;
bounds: InfraWaffleMapBounds;
dataBounds: InfraWaffleMapBounds;
}
export const Map: React.SFC<Props> = ({
@ -36,6 +37,7 @@ export const Map: React.SFC<Props> = ({
formatter,
bounds,
nodeType,
dataBounds,
}) => {
const map = nodesToWaffleMap(nodes);
return (
@ -80,7 +82,12 @@ export const Map: React.SFC<Props> = ({
}
})}
</WaffleMapInnerContainer>
<Legend formatter={formatter} bounds={bounds} legend={options.legend} />
<Legend
formatter={formatter}
bounds={bounds}
dataBounds={dataBounds}
legend={options.legend}
/>
</WaffleMapOuterContainer>
);
}}

View file

@ -48,7 +48,7 @@ export const StepLegend: React.SFC<Props> = ({ legend, formatter }) => {
const StepLegendContainer = styled.div`
display: flex;
padding: 10px;
padding: 10px 40px 10px 10px;
`;
const StepContainer = styled.div`

View file

@ -8,6 +8,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { isBoolean, isNumber } from 'lodash';
import {
InfraMetricInput,
InfraMetricType,
@ -26,12 +27,16 @@ const selectOptionsUrlState = createSelector(
waffleOptionsSelectors.selectGroupBy,
waffleOptionsSelectors.selectNodeType,
waffleOptionsSelectors.selectCustomOptions,
(metric, view, groupBy, nodeType, customOptions) => ({
waffleOptionsSelectors.selectBoundsOverride,
waffleOptionsSelectors.selectAutoBounds,
(metric, view, groupBy, nodeType, customOptions, boundsOverride, autoBounds) => ({
metric,
groupBy,
nodeType,
view,
customOptions,
boundsOverride,
autoBounds,
})
);
@ -42,6 +47,8 @@ export const withWaffleOptions = connect(
nodeType: waffleOptionsSelectors.selectNodeType(state),
view: waffleOptionsSelectors.selectView(state),
customOptions: waffleOptionsSelectors.selectCustomOptions(state),
boundsOverride: waffleOptionsSelectors.selectBoundsOverride(state),
autoBounds: waffleOptionsSelectors.selectAutoBounds(state),
urlState: selectOptionsUrlState(state),
}),
bindPlainActionCreators({
@ -50,6 +57,8 @@ export const withWaffleOptions = connect(
changeNodeType: waffleOptionsActions.changeNodeType,
changeView: waffleOptionsActions.changeView,
changeCustomOptions: waffleOptionsActions.changeCustomOptions,
changeBoundsOverride: waffleOptionsActions.changeBoundsOverride,
changeAutoBounds: waffleOptionsActions.changeAutoBounds,
})
);
@ -65,6 +74,8 @@ interface WaffleOptionsUrlState {
nodeType?: ReturnType<typeof waffleOptionsSelectors.selectNodeType>;
view?: ReturnType<typeof waffleOptionsSelectors.selectView>;
customOptions?: ReturnType<typeof waffleOptionsSelectors.selectCustomOptions>;
bounds?: ReturnType<typeof waffleOptionsSelectors.selectBoundsOverride>;
auto?: ReturnType<typeof waffleOptionsSelectors.selectAutoBounds>;
}
export const WithWaffleOptionsUrlState = () => (
@ -76,6 +87,8 @@ export const WithWaffleOptionsUrlState = () => (
changeNodeType,
changeView,
changeCustomOptions,
changeAutoBounds,
changeBoundsOverride,
}) => (
<UrlStateContainer
urlState={urlState}
@ -97,6 +110,12 @@ export const WithWaffleOptionsUrlState = () => (
if (newUrlState && newUrlState.customOptions) {
changeCustomOptions(newUrlState.customOptions);
}
if (newUrlState && newUrlState.bounds) {
changeBoundsOverride(newUrlState.bounds);
}
if (newUrlState && newUrlState.auto) {
changeAutoBounds(newUrlState.auto);
}
}}
onInitialize={initialUrlState => {
if (initialUrlState && initialUrlState.metric) {
@ -114,6 +133,12 @@ export const WithWaffleOptionsUrlState = () => (
if (initialUrlState && initialUrlState.customOptions) {
changeCustomOptions(initialUrlState.customOptions);
}
if (initialUrlState && initialUrlState.bounds) {
changeBoundsOverride(initialUrlState.bounds);
}
if (initialUrlState && initialUrlState.auto) {
changeAutoBounds(initialUrlState.auto);
}
}}
/>
)}
@ -128,6 +153,8 @@ const mapToUrlState = (value: any): WaffleOptionsUrlState | undefined =>
nodeType: mapToNodeTypeUrlState(value.nodeType),
view: mapToViewUrlState(value.view),
customOptions: mapToCustomOptionsUrlState(value.customOptions),
bounds: mapToBoundsOverideUrlState(value.boundsOverride),
auto: mapToAutoBoundsUrlState(value.autoBounds),
}
: undefined;
@ -169,3 +196,11 @@ const mapToCustomOptionsUrlState = (subject: any) => {
? subject
: undefined;
};
const mapToBoundsOverideUrlState = (subject: any) => {
return subject != null && isNumber(subject.max) && isNumber(subject.min) ? subject : undefined;
};
const mapToAutoBoundsUrlState = (subject: any) => {
return subject != null && isBoolean(subject) ? subject : undefined;
};

View file

@ -27,7 +27,15 @@ export const HomePageContent: React.SFC = () => (
<WithWaffleTime>
{({ currentTimeRange, isAutoReloading }) => (
<WithWaffleOptions>
{({ metric, groupBy, nodeType, view, changeView }) => (
{({
metric,
groupBy,
nodeType,
view,
changeView,
autoBounds,
boundsOverride,
}) => (
<WithWaffleNodes
filterQuery={filterQueryAsJson}
metric={metric}
@ -52,6 +60,8 @@ export const HomePageContent: React.SFC = () => (
timeRange={currentTimeRange}
view={view}
onViewChange={changeView}
autoBounds={autoBounds}
boundsOverride={boundsOverride}
/>
)}
</WithWaffleNodes>

View file

@ -6,7 +6,7 @@
import actionCreatorFactory from 'typescript-fsa';
import { InfraMetricInput, InfraNodeType, InfraPathInput } from '../../../graphql/types';
import { InfraGroupByOptions } from '../../../lib/lib';
import { InfraGroupByOptions, InfraWaffleMapBounds } from '../../../lib/lib';
const actionCreator = actionCreatorFactory('x-pack/infra/local/waffle_options');
@ -15,3 +15,5 @@ export const changeGroupBy = actionCreator<InfraPathInput[]>('CHANGE_GROUP_BY');
export const changeCustomOptions = actionCreator<InfraGroupByOptions[]>('CHANGE_CUSTOM_OPTIONS');
export const changeNodeType = actionCreator<InfraNodeType>('CHANGE_NODE_TYPE');
export const changeView = actionCreator<string>('CHANGE_VIEW');
export const changeBoundsOverride = actionCreator<InfraWaffleMapBounds>('CHANGE_BOUNDS_OVERRIDE');
export const changeAutoBounds = actionCreator<boolean>('CHANGE_AUTO_BOUNDS');

View file

@ -13,8 +13,10 @@ import {
InfraNodeType,
InfraPathInput,
} from '../../../graphql/types';
import { InfraGroupByOptions } from '../../../lib/lib';
import { InfraGroupByOptions, InfraWaffleMapBounds } from '../../../lib/lib';
import {
changeAutoBounds,
changeBoundsOverride,
changeCustomOptions,
changeGroupBy,
changeMetric,
@ -28,6 +30,8 @@ export interface WaffleOptionsState {
nodeType: InfraNodeType;
view: string;
customOptions: InfraGroupByOptions[];
boundsOverride: InfraWaffleMapBounds;
autoBounds: boolean;
}
export const initialWaffleOptionsState: WaffleOptionsState = {
@ -36,6 +40,8 @@ export const initialWaffleOptionsState: WaffleOptionsState = {
nodeType: InfraNodeType.host,
view: 'map',
customOptions: [],
boundsOverride: { max: 1, min: 0 },
autoBounds: true,
};
const currentMetricReducer = reducerWithInitialState(initialWaffleOptionsState.metric).case(
@ -62,10 +68,21 @@ const currentViewReducer = reducerWithInitialState(initialWaffleOptionsState.vie
(current, target) => target
);
const currentBoundsOverrideReducer = reducerWithInitialState(
initialWaffleOptionsState.boundsOverride
).case(changeBoundsOverride, (current, target) => target);
const currentAutoBoundsReducer = reducerWithInitialState(initialWaffleOptionsState.autoBounds).case(
changeAutoBounds,
(current, target) => target
);
export const waffleOptionsReducer = combineReducers<WaffleOptionsState>({
metric: currentMetricReducer,
groupBy: currentGroupByReducer,
nodeType: currentNodeTypeReducer,
view: currentViewReducer,
customOptions: currentCustomOptionsReducer,
boundsOverride: currentBoundsOverrideReducer,
autoBounds: currentAutoBoundsReducer,
});

View file

@ -11,3 +11,5 @@ export const selectGroupBy = (state: WaffleOptionsState) => state.groupBy;
export const selectCustomOptions = (state: WaffleOptionsState) => state.customOptions;
export const selectNodeType = (state: WaffleOptionsState) => state.nodeType;
export const selectView = (state: WaffleOptionsState) => state.view;
export const selectBoundsOverride = (state: WaffleOptionsState) => state.boundsOverride;
export const selectAutoBounds = (state: WaffleOptionsState) => state.autoBounds;