React: refactor away from unsafe lifecycle methods (#21291)

* refactor aliasBy and PopoverCtrl components

* refactor Aggregations component

* refactor MetricSelect component

* refactor UserProvider

* popoverCtr: remove redundant logic

* simplified the MetricsSelect a bit.

* skipping testing of internal component logic.

* changed to componentWillMount.

* changed elapsed time to a functional component.

* rewrote the tests.

* fixed missing test title.

* fixed a tiny issue with elapsed time.

* rename of field.

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
Boyko 2020-05-18 19:03:29 +03:00 committed by GitHub
parent 44fae66bc0
commit 1e74037eae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 242 additions and 278 deletions

View File

@ -48,63 +48,27 @@ interface Props {
} }
interface State { interface State {
placement: PopperJS.Placement;
show: boolean; show: boolean;
} }
class PopoverController extends React.Component<Props, State> { class PopoverController extends React.Component<Props, State> {
private hideTimeout: any; private hideTimeout: any;
state = { show: false };
constructor(props: Props) {
super(props);
this.state = {
placement: this.props.placement || 'auto',
show: false,
};
}
UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (nextProps.placement && nextProps.placement !== this.state.placement) {
this.setState((prevState: State) => {
return {
...prevState,
placement: nextProps.placement || 'auto',
};
});
}
}
showPopper = () => { showPopper = () => {
if (this.hideTimeout) { clearTimeout(this.hideTimeout);
clearTimeout(this.hideTimeout); this.setState({ show: true });
}
this.setState(prevState => ({
...prevState,
show: true,
}));
}; };
hidePopper = () => { hidePopper = () => {
if (this.props.hideAfter !== 0) { this.hideTimeout = setTimeout(() => {
this.hideTimeout = setTimeout(() => { this.setState({ show: false });
this.setState(prevState => ({ }, this.props.hideAfter);
...prevState,
show: false,
}));
}, this.props.hideAfter);
return;
}
this.setState(prevState => ({
...prevState,
show: false,
}));
}; };
render() { render() {
const { children, content } = this.props; const { children, content, placement = 'auto' } = this.props;
const { show, placement } = this.state; const { show } = this.state;
return children(this.showPopper, this.hidePopper, { return children(this.showPopper, this.hidePopper, {
show, show,

View File

@ -0,0 +1,48 @@
import React from 'react';
import { shallow } from 'enzyme';
import { MetricSelect } from './MetricSelect';
import { LegacyForms } from '@grafana/ui';
const { Select } = LegacyForms;
describe('MetricSelect', () => {
describe('When receive props', () => {
it('should pass correct set of props to Select component', () => {
const props: any = {
placeholder: 'Select Reducer',
className: 'width-15',
options: [],
variables: [],
};
const wrapper = shallow(<MetricSelect {...props} />);
expect(wrapper.find(Select).props()).toMatchObject({
className: 'width-15',
isMulti: false,
isClearable: false,
backspaceRemovesValue: false,
isSearchable: true,
maxMenuHeight: 500,
placeholder: 'Select Reducer',
});
});
it('should pass callbacks correctly to the Select component', () => {
const spyOnChange = jest.fn();
const props: any = {
onChange: spyOnChange,
options: [],
variables: [],
};
const wrapper = shallow(<MetricSelect {...props} />);
wrapper
.find(Select)
.props()
.onChange({ value: 'foo' });
expect(
wrapper
.find(Select)
.props()
.noOptionsMessage()
).toEqual('No options found');
expect(spyOnChange).toHaveBeenCalledWith('foo');
});
});
});

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useMemo, useCallback, FC } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import { LegacyForms } from '@grafana/ui'; import { LegacyForms } from '@grafana/ui';
@ -16,75 +16,51 @@ export interface Props {
variables?: Variable[]; variables?: Variable[];
} }
interface State { export const MetricSelect: FC<Props> = props => {
options: Array<SelectableValue<string>>; const { value, placeholder, className, isSearchable, onChange } = props;
} const options = useSelectOptions(props);
const selected = useSelectedOption(options, value);
const onChangeValue = useCallback((selectable: SelectableValue<string>) => onChange(selectable.value), [onChange]);
export class MetricSelect extends React.Component<Props, State> { return (
static defaultProps: Partial<Props> = { <Select
variables: [], className={className}
options: [], isMulti={false}
isSearchable: true, isClearable={false}
}; backspaceRemovesValue={false}
onChange={onChangeValue}
options={options}
isSearchable={isSearchable}
maxMenuHeight={500}
placeholder={placeholder}
noOptionsMessage={() => 'No options found'}
value={selected}
/>
);
};
constructor(props: Props) { const useSelectOptions = ({ variables = [], options }: Props): Array<SelectableValue<string>> => {
super(props); return useMemo(() => {
this.state = { options: [] }; if (!Array.isArray(variables) || variables.length === 0) {
} return options;
componentDidMount() {
this.setState({ options: this.buildOptions(this.props) });
}
UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (nextProps.options.length > 0 || nextProps.variables?.length) {
this.setState({ options: this.buildOptions(nextProps) });
} }
}
shouldComponentUpdate(nextProps: Props) { return [
const nextOptions = this.buildOptions(nextProps); {
return nextProps.value !== this.props.value || !_.isEqual(nextOptions, this.state.options); label: 'Template Variables',
} options: variables.map(({ name }) => ({
label: `$${name}`,
value: `$${name}`,
})),
},
...options,
];
}, [variables, options]);
};
buildOptions({ variables = [], options }: Props) { const useSelectedOption = (options: Array<SelectableValue<string>>, value: string): SelectableValue<string> => {
return variables.length > 0 ? [this.getVariablesGroup(), ...options] : options; return useMemo(() => {
}
getVariablesGroup() {
return {
label: 'Template Variables',
options: this.props.variables?.map(v => ({
label: `$${v.name}`,
value: `$${v.name}`,
})),
};
}
getSelectedOption() {
const { options } = this.state;
const allOptions = options.every(o => o.options) ? _.flatten(options.map(o => o.options)) : options; const allOptions = options.every(o => o.options) ? _.flatten(options.map(o => o.options)) : options;
return allOptions.find(option => option.value === this.props.value); return allOptions.find(option => option.value === value);
} }, [options, value]);
};
render() {
const { placeholder, className, isSearchable, onChange } = this.props;
const { options } = this.state;
const selectedOption = this.getSelectedOption();
return (
<Select
className={className}
isMulti={false}
isClearable={false}
backspaceRemovesValue={false}
onChange={item => onChange(item.value ?? '')}
options={options}
isSearchable={isSearchable}
maxMenuHeight={500}
placeholder={placeholder}
noOptionsMessage={() => 'No options found'}
value={selectedOption}
/>
);
}
}

View File

@ -1,76 +1,22 @@
import React, { PureComponent } from 'react'; import React, { FC, useState, useEffect } from 'react';
import { toDuration } from '@grafana/data'; import { useInterval } from 'react-use';
import { Time, TimeProps } from './Time';
const INTERVAL = 150; const INTERVAL = 150;
export interface Props { export interface ElapsedTimeProps extends Omit<TimeProps, 'timeInMs'> {
time?: number;
// Use this to reset the timer. Any value is allowed just need to be !== from the previous. // Use this to reset the timer. Any value is allowed just need to be !== from the previous.
// Keep in mind things like [] !== [] or {} !== {}. // Keep in mind things like [] !== [] or {} !== {}.
resetKey?: any; resetKey?: any;
className?: string;
humanize?: boolean;
} }
export interface State { export const ElapsedTime: FC<ElapsedTimeProps> = ({ resetKey, humanize, className }) => {
elapsed: number; const [elapsed, setElapsed] = useState(0); // the current value of elapsed
}
/** // hook that will schedule a interval and then update the elapsed value on every tick.
* Shows an incremental time ticker of elapsed time from some event. useInterval(() => setElapsed(elapsed + INTERVAL), INTERVAL);
*/ // this effect will only be run when resetKey changes. This will reset the elapsed to 0.
export default class ElapsedTime extends PureComponent<Props, State> { useEffect(() => setElapsed(0), [resetKey]);
offset: number;
timer: number;
state = { return <Time timeInMs={elapsed} className={className} humanize={humanize} />;
elapsed: 0, };
};
start() {
this.offset = Date.now();
this.timer = window.setInterval(this.tick, INTERVAL);
}
tick = () => {
const jetzt = Date.now();
const elapsed = jetzt - this.offset;
this.setState({ elapsed });
};
UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (nextProps.time) {
clearInterval(this.timer);
} else if (this.props.time) {
this.start();
}
if (nextProps.resetKey !== this.props.resetKey) {
clearInterval(this.timer);
this.start();
}
}
componentDidMount() {
this.start();
}
componentWillUnmount() {
clearInterval(this.timer);
}
render() {
const { elapsed } = this.state;
const { className, time, humanize } = this.props;
const value = (time || elapsed) / 1000;
let displayValue = `${value.toFixed(1)}s`;
if (humanize) {
const duration = toDuration(elapsed);
const hours = duration.hours();
const minutes = duration.minutes();
const seconds = duration.seconds();
displayValue = hours ? `${hours}h ${minutes}m ${seconds}s` : minutes ? ` ${minutes}m ${seconds}s` : `${seconds}s`;
}
return <span className={`elapsed-time ${className}`}>{displayValue}</span>;
}
}

View File

@ -5,7 +5,7 @@ import tinycolor from 'tinycolor2';
import { Themeable, withTheme, getLogRowStyles, Icon } from '@grafana/ui'; import { Themeable, withTheme, getLogRowStyles, Icon } from '@grafana/ui';
import { GrafanaTheme, LogRowModel, TimeZone, dateTimeFormat } from '@grafana/data'; import { GrafanaTheme, LogRowModel, TimeZone, dateTimeFormat } from '@grafana/data';
import ElapsedTime from './ElapsedTime'; import { ElapsedTime } from './ElapsedTime';
const getStyles = (theme: GrafanaTheme) => ({ const getStyles = (theme: GrafanaTheme) => ({
logsRowsLive: css` logsRowsLive: css`

View File

@ -1,6 +1,6 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import ElapsedTime from './ElapsedTime'; import { ElapsedTime } from './ElapsedTime';
import { PanelData, LoadingState } from '@grafana/data'; import { PanelData, LoadingState } from '@grafana/data';
function formatLatency(value: number) { function formatLatency(value: number) {

View File

@ -0,0 +1,35 @@
import React, { FC } from 'react';
import { toDuration } from '@grafana/data';
export interface TimeProps {
timeInMs: number;
className?: string;
humanize?: boolean;
}
export const Time: FC<TimeProps> = ({ timeInMs, className, humanize }) => {
return <span className={`elapsed-time ${className}`}>{formatTime(timeInMs, humanize)}</span>;
};
const formatTime = (timeInMs: number, humanize = false): string => {
const inSeconds = timeInMs / 1000;
if (!humanize) {
return `${inSeconds.toFixed(1)}s`;
}
const duration = toDuration(inSeconds, 'seconds');
const hours = duration.hours();
const minutes = duration.minutes();
const seconds = duration.seconds();
if (hours) {
return `${hours}h ${minutes}m ${seconds}s`;
}
if (minutes) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
};

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import { Aggregations, Props } from './Aggregations';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Segment } from '@grafana/ui';
import { Aggregations, Props } from './Aggregations';
import { ValueTypes, MetricKind } from '../constants'; import { ValueTypes, MetricKind } from '../constants';
import { TemplateSrvStub } from 'test/specs/helpers'; import { TemplateSrvStub } from 'test/specs/helpers';
@ -20,39 +21,49 @@ const props: Props = {
}; };
describe('Aggregations', () => { describe('Aggregations', () => {
let wrapper: any;
it('renders correctly', () => { it('renders correctly', () => {
const tree = renderer.create(<Aggregations {...props} />).toJSON(); const tree = renderer.create(<Aggregations {...props} />).toJSON();
expect(tree).toMatchSnapshot(); expect(tree).toMatchSnapshot();
}); });
describe('options', () => { describe('options', () => {
describe('when DOUBLE and DELTA is passed as props', () => { describe('when DOUBLE and GAUGE is passed as props', () => {
beforeEach(() => { const nextProps = {
const newProps = { ...props, metricDescriptor: { valueType: ValueTypes.DOUBLE, metricKind: MetricKind.GAUGE } }; ...props,
wrapper = shallow(<Aggregations {...newProps} />); metricDescriptor: {
}); valueType: ValueTypes.DOUBLE,
it('', () => { metricKind: MetricKind.GAUGE,
const options = wrapper.state().aggOptions; },
expect(options.length).toEqual(11); };
expect(options.map((o: any) => o.value)).toEqual(
it('should not have the reduce values', () => {
const wrapper = shallow(<Aggregations {...nextProps} />);
const { options } = wrapper.find(Segment).props() as any;
const [, aggGroup] = options;
expect(aggGroup.options.length).toEqual(11);
expect(aggGroup.options.map((o: any) => o.value)).toEqual(
expect.not.arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE']) expect.not.arrayContaining(['REDUCE_COUNT_TRUE', 'REDUCE_COUNT_FALSE'])
); );
}); });
}); });
describe('when MONEY and CUMULATIVE is passed as props', () => { describe('when MONEY and CUMULATIVE is passed as props', () => {
beforeEach(() => { const nextProps = {
const newProps = { ...props,
...props, metricDescriptor: {
metricDescriptor: { valueType: ValueTypes.MONEY, metricKind: MetricKind.CUMULATIVE }, valueType: ValueTypes.MONEY,
}; metricKind: MetricKind.CUMULATIVE,
wrapper = shallow(<Aggregations {...newProps} />); },
}); };
it('', () => {
const options = wrapper.state().aggOptions; it('should have the reduce values', () => {
expect(options.length).toEqual(10); const wrapper = shallow(<Aggregations {...nextProps} />);
expect(options.map((o: any) => o.value)).toEqual(expect.arrayContaining(['REDUCE_NONE'])); const { options } = wrapper.find(Segment).props() as any;
const [, aggGroup] = options;
expect(aggGroup.options.length).toEqual(10);
expect(aggGroup.options.map((o: any) => o.value)).toEqual(expect.arrayContaining(['REDUCE_NONE']));
}); });
}); });
}); });

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { FC, useState, useMemo } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
@ -18,81 +18,65 @@ export interface Props {
templateVariableOptions: Array<SelectableValue<string>>; templateVariableOptions: Array<SelectableValue<string>>;
} }
export interface State { export const Aggregations: FC<Props> = props => {
aggOptions: any[]; const [displayAdvancedOptions, setDisplayAdvancedOptions] = useState(false);
displayAdvancedOptions: boolean; const aggOptions = useAggregationOptionsByMetric(props);
} const selected = useSelectedFromOptions(aggOptions, props);
export class Aggregations extends React.Component<Props, State> { return (
state: State = { <>
aggOptions: [], <div className="gf-form-inline">
displayAdvancedOptions: false, <label className="gf-form-label query-keyword width-9">Aggregation</label>
}; <Segment
onChange={({ value }) => props.onChange(value)}
componentDidMount() { value={selected}
this.setAggOptions(this.props); options={[
} {
label: 'Template Variables',
UNSAFE_componentWillReceiveProps(nextProps: Props) { options: props.templateVariableOptions,
this.setAggOptions(nextProps); },
} {
label: 'Aggregations',
setAggOptions({ metricDescriptor }: Props) { expanded: true,
let aggOptions: any[] = []; options: aggOptions,
if (metricDescriptor) { },
aggOptions = getAggregationOptionsByMetric( ]}
metricDescriptor.valueType as ValueTypes, placeholder="Select Reducer"
metricDescriptor.metricKind as MetricKind ></Segment>
).map(a => ({ <div className="gf-form gf-form--grow">
...a, <label className="gf-form-label gf-form-label--grow">
label: a.text, <a onClick={() => setDisplayAdvancedOptions(!displayAdvancedOptions)}>
})); <>
} <Icon name={displayAdvancedOptions ? 'angle-down' : 'angle-right'} /> Advanced Options
this.setState({ aggOptions }); </>
} </a>
</label>
onToggleDisplayAdvanced = () => {
this.setState(state => ({
displayAdvancedOptions: !state.displayAdvancedOptions,
}));
};
render() {
const { displayAdvancedOptions, aggOptions } = this.state;
const { templateVariableOptions, onChange, crossSeriesReducer } = this.props;
return (
<>
<div className="gf-form-inline">
<label className="gf-form-label query-keyword width-9">Aggregation</label>
<Segment
onChange={({ value }) => onChange(value)}
value={[...aggOptions, ...templateVariableOptions].find(s => s.value === crossSeriesReducer)}
options={[
{
label: 'Template Variables',
options: templateVariableOptions,
},
{
label: 'Aggregations',
expanded: true,
options: aggOptions,
},
]}
placeholder="Select Reducer"
></Segment>
<div className="gf-form gf-form--grow">
<label className="gf-form-label gf-form-label--grow">
<a onClick={this.onToggleDisplayAdvanced}>
<>
<Icon name={displayAdvancedOptions ? 'angle-down' : 'angle-right'} /> Advanced Options
</>
</a>
</label>
</div>
</div> </div>
{this.props.children && this.props.children(this.state.displayAdvancedOptions)} </div>
</> {props.children(displayAdvancedOptions)}
); </>
} );
} };
const useAggregationOptionsByMetric = ({ metricDescriptor }: Props): Array<SelectableValue<string>> => {
return useMemo(() => {
if (!metricDescriptor) {
return [];
}
return getAggregationOptionsByMetric(
metricDescriptor.valueType as ValueTypes,
metricDescriptor.metricKind as MetricKind
).map(a => ({
...a,
label: a.text,
}));
}, [metricDescriptor?.metricKind, metricDescriptor?.valueType]);
};
const useSelectedFromOptions = (aggOptions: Array<SelectableValue<string>>, props: Props) => {
return useMemo(() => {
const allOptions = [...aggOptions, ...props.templateVariableOptions];
return allOptions.find(s => s.value === props.crossSeriesReducer);
}, [aggOptions, props.crossSeriesReducer, props.templateVariableOptions]);
};