split autocomplete dropdown in to groups (#6211)

* split autocomplete dropdown in to groups

Signed-off-by: blalov <boyko.lalov@tick42.com>

* fix autocomplete flickering

Signed-off-by: blalov <boyko.lalov@tick42.com>

* fix expression input issue.

Signed-off-by: blalov <boyko.lalov@tick42.com>

* select autocomplete item issue fix

Signed-off-by: blalov <boyko.lalov@tick42.com>

* remove metric group abstraction

Signed-off-by: blalov <boyko.lalov@tick42.com>
pull/6225/head
Boyko 2019-10-26 20:50:22 +03:00 committed by Julius Volz
parent 1afa476b8a
commit dab87ca281
6 changed files with 117 additions and 112 deletions

View File

@ -75,7 +75,6 @@ button.execute-btn {
top: 100%; top: 100%;
left: 56px; left: 56px;
float: left; float: left;
padding: .5rem 1px .5rem 1px;
margin: -5px; margin: -5px;
list-style: none; list-style: none;
} }
@ -90,6 +89,14 @@ button.execute-btn {
display: block; display: block;
} }
.autosuggest-dropdown .card-header {
background: rgba(240, 230, 140, 0.4);
height: 30px;
display: flex;
align-items: center;
text-transform: uppercase;
}
.graph-controls, .table-controls { .graph-controls, .table-controls {
margin-bottom: 10px; margin-bottom: 10px;
} }

View File

@ -1,18 +1,15 @@
import React, { FC, HTMLProps, memo } from 'react'; import React, { FC, memo, CSSProperties } from 'react';
import { FormGroup, Label, Input } from 'reactstrap'; import { FormGroup, Label, Input, InputProps } from 'reactstrap';
import { uuidGen } from './utils/func';
const Checkbox: FC<HTMLProps<HTMLSpanElement>> = ({ children, onChange, style }) => { interface CheckboxProps extends InputProps {
const id = uuidGen(); wrapperStyles?: CSSProperties;
}
const Checkbox: FC<CheckboxProps> = ({ children, wrapperStyles, id, ...rest }) => {
return ( return (
<FormGroup className="custom-control custom-checkbox" style={style}> <FormGroup className="custom-control custom-checkbox" style={wrapperStyles}>
<Input <Input {...rest} id={id} type="checkbox" className="custom-control-input" />
onChange={onChange} <Label style={{ userSelect: 'none' }} className="custom-control-label" for={id}>
type="checkbox"
className="custom-control-input"
id={`checkbox_${id}`}
placeholder="password placeholder" />
<Label style={{ userSelect: 'none' }} className="custom-control-label" for={`checkbox_${id}`}>
{children} {children}
</Label> </Label>
</FormGroup> </FormGroup>

View File

@ -5,6 +5,10 @@ import {
InputGroupAddon, InputGroupAddon,
InputGroupText, InputGroupText,
Input, Input,
ListGroup,
ListGroupItem,
Card,
CardHeader,
} from 'reactstrap'; } from 'reactstrap';
import Downshift, { ControllerStateAndHelpers } from 'downshift'; import Downshift, { ControllerStateAndHelpers } from 'downshift';
@ -16,7 +20,7 @@ import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons';
interface ExpressionInputProps { interface ExpressionInputProps {
value: string; value: string;
metricNames: string[]; autocompleteSections: { [key: string]: string[] };
executeQuery: (expr: string) => void; executeQuery: (expr: string) => void;
loading: boolean; loading: boolean;
} }
@ -27,7 +31,6 @@ interface ExpressionInputState {
} }
class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputState> { class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputState> {
private prevNoMatchValue: string | null = null;
private exprInputRef = React.createRef<HTMLInputElement>(); private exprInputRef = React.createRef<HTMLInputElement>();
constructor(props: ExpressionInputProps) { constructor(props: ExpressionInputProps) {
@ -42,7 +45,6 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
this.setHeight(); this.setHeight();
} }
setHeight = () => { setHeight = () => {
const { offsetHeight, clientHeight, scrollHeight } = this.exprInputRef.current!; const { offsetHeight, clientHeight, scrollHeight } = this.exprInputRef.current!;
const offset = offsetHeight - clientHeight; // Needed in order for the height to be more accurate. const offset = offsetHeight - clientHeight; // Needed in order for the height to be more accurate.
@ -57,7 +59,7 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
} }
handleDropdownSelection = (value: string) => { handleDropdownSelection = (value: string) => {
this.setState({ value, height: 'auto' }, this.setHeight) this.setState({ value, height: 'auto' }, this.setHeight);
}; };
handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => { handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
@ -67,53 +69,60 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
} }
} }
executeQuery = () => this.props.executeQuery(this.exprInputRef.current!.value) executeQuery = () => this.props.executeQuery(this.exprInputRef.current!.value);
renderAutosuggest = (downshift: ControllerStateAndHelpers<any>) => { getSearchMatches = (input: string, expressions: string[]) => {
const { inputValue } = downshift return fuzzy.filter(input.replace(/ /g, ''), expressions, {
if (!inputValue || (this.prevNoMatchValue && inputValue.includes(this.prevNoMatchValue))) {
// This is ugly but is needed in order to sync state updates.
// This way we force downshift to wait React render call to complete before closeMenu to be triggered.
setTimeout(downshift.closeMenu);
return null;
}
const matches = fuzzy.filter(inputValue.replace(/ /g, ''), this.props.metricNames, {
pre: "<strong>", pre: "<strong>",
post: "</strong>", post: "</strong>",
}); });
}
if (matches.length === 0) { createAutocompleteSection = (downshift: ControllerStateAndHelpers<any>) => {
this.prevNoMatchValue = inputValue; const { inputValue = '', closeMenu, highlightedIndex } = downshift;
setTimeout(downshift.closeMenu); const { autocompleteSections } = this.props;
let index = 0;
const sections = inputValue!.length ?
Object.entries(autocompleteSections).reduce((acc, [title, items]) => {
const matches = this.getSearchMatches(inputValue!, items);
return !matches.length ? acc : [
...acc,
<Card tag={ListGroup} key={title}>
<CardHeader style={{ fontSize: 13 }}>{title}</CardHeader>
{
matches
.slice(0, 100) // Limit DOM rendering to 100 results, as DOM rendering is sloooow.
.map(({ original, string }) => {
const itemProps = downshift.getItemProps({
key: original,
index,
item: original,
style: {
backgroundColor: highlightedIndex === index++ ? 'lightgray' : 'white',
},
})
return (
<SanitizeHTML tag={ListGroupItem} {...itemProps} allowedTags={['strong']}>
{string}
</SanitizeHTML>
);
})
}
</Card>
]
}, [] as JSX.Element[]) : []
if (!sections.length) {
// This is ugly but is needed in order to sync state updates.
// This way we force downshift to wait React render call to complete before closeMenu to be triggered.
setTimeout(closeMenu);
return null; return null;
} }
return ( return (
<ul className="autosuggest-dropdown" {...downshift.getMenuProps()}> <div {...downshift.getMenuProps()} className="autosuggest-dropdown">
{ { sections }
matches </div>
.slice(0, 200) // Limit DOM rendering to 100 results, as DOM rendering is sloooow.
.map((item, index) => (
<li
{...downshift.getItemProps({
key: item.original,
index,
item: item.original,
style: {
backgroundColor:
downshift.highlightedIndex === index ? 'lightgray' : 'white',
fontWeight: downshift.selectedItem === item ? 'bold' : 'normal',
},
})}
>
<SanitizeHTML inline={true} allowedTags={['strong']}>
{item.string}
</SanitizeHTML>
</li>
))
}
</ul>
); );
} }
@ -121,8 +130,8 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
const { value, height } = this.state; const { value, height } = this.state;
return ( return (
<Downshift <Downshift
onChange={this.handleDropdownSelection}
inputValue={value} inputValue={value}
onSelect={this.handleDropdownSelection}
> >
{(downshift) => ( {(downshift) => (
<div> <div>
@ -179,7 +188,7 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
</Button> </Button>
</InputGroupAddon> </InputGroupAddon>
</InputGroup> </InputGroup>
{downshift.isOpen && this.renderAutosuggest(downshift)} {downshift.isOpen && this.createAutocompleteSection(downshift)}
</div> </div>
)} )}
</Downshift> </Downshift>

View File

@ -24,6 +24,7 @@ import QueryStatsView, { QueryStats } from './QueryStatsView';
interface PanelProps { interface PanelProps {
options: PanelOptions; options: PanelOptions;
onOptionsChanged: (opts: PanelOptions) => void; onOptionsChanged: (opts: PanelOptions) => void;
pastQueries: string[];
metricNames: string[]; metricNames: string[];
removePanel: () => void; removePanel: () => void;
onExecuteQuery: (query: string) => void; onExecuteQuery: (query: string) => void;
@ -228,15 +229,19 @@ class Panel extends Component<PanelProps, PanelState> {
} }
render() { render() {
const { pastQueries, metricNames, options } = this.props;
return ( return (
<div className="panel"> <div className="panel">
<Row> <Row>
<Col> <Col>
<ExpressionInput <ExpressionInput
value={this.props.options.expr} value={options.expr}
executeQuery={this.executeQuery} executeQuery={this.executeQuery}
loading={this.state.loading} loading={this.state.loading}
metricNames={this.props.metricNames} autocompleteSections={{
'Query History': pastQueries,
'Metric Names': metricNames,
}}
/> />
</Col> </Col>
</Row> </Row>
@ -250,7 +255,7 @@ class Panel extends Component<PanelProps, PanelState> {
<Nav tabs> <Nav tabs>
<NavItem> <NavItem>
<NavLink <NavLink
className={this.props.options.type === 'table' ? 'active' : ''} className={options.type === 'table' ? 'active' : ''}
onClick={() => { this.setOptions({type: 'table'}); }} onClick={() => { this.setOptions({type: 'table'}); }}
> >
Table Table
@ -258,7 +263,7 @@ class Panel extends Component<PanelProps, PanelState> {
</NavItem> </NavItem>
<NavItem> <NavItem>
<NavLink <NavLink
className={this.props.options.type === 'graph' ? 'active' : ''} className={options.type === 'graph' ? 'active' : ''}
onClick={() => { this.setOptions({type: 'graph'}); }} onClick={() => { this.setOptions({type: 'graph'}); }}
> >
Graph Graph
@ -269,14 +274,14 @@ class Panel extends Component<PanelProps, PanelState> {
<QueryStatsView {...this.state.stats} /> <QueryStatsView {...this.state.stats} />
} }
</Nav> </Nav>
<TabContent activeTab={this.props.options.type}> <TabContent activeTab={options.type}>
<TabPane tabId="table"> <TabPane tabId="table">
{this.props.options.type === 'table' && {options.type === 'table' &&
<> <>
<div className="table-controls"> <div className="table-controls">
<TimeInput <TimeInput
time={this.props.options.endTime} time={options.endTime}
range={this.props.options.range} range={options.range}
placeholder="Evaluation time" placeholder="Evaluation time"
onChangeTime={this.handleChangeEndTime} onChangeTime={this.handleChangeEndTime}
/> />
@ -289,17 +294,17 @@ class Panel extends Component<PanelProps, PanelState> {
{this.props.options.type === 'graph' && {this.props.options.type === 'graph' &&
<> <>
<GraphControls <GraphControls
range={this.props.options.range} range={options.range}
endTime={this.props.options.endTime} endTime={options.endTime}
resolution={this.props.options.resolution} resolution={options.resolution}
stacked={this.props.options.stacked} stacked={options.stacked}
onChangeRange={this.handleChangeRange} onChangeRange={this.handleChangeRange}
onChangeEndTime={this.handleChangeEndTime} onChangeEndTime={this.handleChangeEndTime}
onChangeResolution={this.handleChangeResolution} onChangeResolution={this.handleChangeResolution}
onChangeStacking={this.handleChangeStacking} onChangeStacking={this.handleChangeStacking}
/> />
<Graph data={this.state.data} stacked={this.props.options.stacked} queryParams={this.state.lastQueryParams} /> <Graph data={this.state.data} stacked={options.stacked} queryParams={this.state.lastQueryParams} />
</> </>
} }
</TabPane> </TabPane>

View File

@ -6,11 +6,14 @@ import Panel, { PanelOptions, PanelDefaultOptions } from './Panel';
import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from './utils/urlParams'; import { decodePanelOptionsFromQueryString, encodePanelOptionsToQueryString } from './utils/urlParams';
import Checkbox from './Checkbox'; import Checkbox from './Checkbox';
export type MetricGroup = { title: string; items: string[] };
interface PanelListState { interface PanelListState {
panels: { panels: {
key: string; key: string;
options: PanelOptions; options: PanelOptions;
}[], }[];
pastQueries: string[];
metricNames: string[]; metricNames: string[];
fetchMetricsError: string | null; fetchMetricsError: string | null;
timeDriftError: string | null; timeDriftError: string | null;
@ -18,7 +21,6 @@ interface PanelListState {
class PanelList extends Component<any, PanelListState> { class PanelList extends Component<any, PanelListState> {
private key: number = 0; private key: number = 0;
private initialMetricNames: string[] = [];
constructor(props: any) { constructor(props: any) {
super(props); super(props);
@ -31,6 +33,7 @@ class PanelList extends Component<any, PanelListState> {
options: PanelDefaultOptions, options: PanelDefaultOptions,
}, },
], ],
pastQueries: [],
metricNames: [], metricNames: [],
fetchMetricsError: null, fetchMetricsError: null,
timeDriftError: null, timeDriftError: null,
@ -47,8 +50,7 @@ class PanelList extends Component<any, PanelListState> {
} }
}) })
.then(json => { .then(json => {
this.initialMetricNames = json.data; this.setMetrics(json.data);
this.setMetrics();
}) })
.catch(error => this.setState({ fetchMetricsError: error.message })); .catch(error => this.setState({ fetchMetricsError: error.message }));
@ -88,20 +90,15 @@ class PanelList extends Component<any, PanelListState> {
this.setMetrics(); this.setMetrics();
} }
setMetrics = () => { setMetrics = (metricNames: string[] = this.state.metricNames) => {
if (this.isHistoryEnabled()) { this.setState({
const historyItems = this.getHistoryItems(); pastQueries: this.isHistoryEnabled() ? this.getHistoryItems() : [],
const { length } = historyItems; metricNames
this.setState({ });
metricNames: [...historyItems.slice(length - 50, length), ...this.initialMetricNames],
});
} else {
this.setState({ metricNames: this.initialMetricNames });
}
} }
handleQueryHistory = (query: string) => { handleQueryHistory = (query: string) => {
const isSimpleMetric = this.initialMetricNames.indexOf(query) !== -1; const isSimpleMetric = this.state.metricNames.indexOf(query) !== -1;
if (isSimpleMetric || !query.length) { if (isSimpleMetric || !query.length) {
return; return;
} }
@ -109,7 +106,7 @@ class PanelList extends Component<any, PanelListState> {
const extendedItems = historyItems.reduce((acc, metric) => { const extendedItems = historyItems.reduce((acc, metric) => {
return metric === query ? acc : [...acc, metric]; // Prevent adding query twice. return metric === query ? acc : [...acc, metric]; // Prevent adding query twice.
}, [query]); }, [query]);
localStorage.setItem('history', JSON.stringify(extendedItems)); localStorage.setItem('history', JSON.stringify(extendedItems.slice(0, 50)));
this.setMetrics(); this.setMetrics();
} }
@ -152,13 +149,15 @@ class PanelList extends Component<any, PanelListState> {
} }
render() { render() {
const { metricNames, pastQueries, timeDriftError, fetchMetricsError } = this.state;
return ( return (
<> <>
<Row className="mb-2"> <Row className="mb-2">
<Checkbox <Checkbox
style={{ margin: '0 0 0 15px', alignSelf: 'center' }} id="query-history-checkbox"
wrapperStyles={{ margin: '0 0 0 15px', alignSelf: 'center' }}
onChange={this.toggleQueryHistory} onChange={this.toggleQueryHistory}
checked={this.isHistoryEnabled()}> defaultChecked={this.isHistoryEnabled()}>
Enable query history Enable query history
</Checkbox> </Checkbox>
<Col> <Col>
@ -173,12 +172,12 @@ class PanelList extends Component<any, PanelListState> {
</Row> </Row>
<Row> <Row>
<Col> <Col>
{this.state.timeDriftError && <Alert color="danger"><strong>Warning:</strong> Error fetching server time: {this.state.timeDriftError}</Alert>} {timeDriftError && <Alert color="danger"><strong>Warning:</strong> Error fetching server time: {this.state.timeDriftError}</Alert>}
</Col> </Col>
</Row> </Row>
<Row> <Row>
<Col> <Col>
{this.state.fetchMetricsError && <Alert color="danger"><strong>Warning:</strong> Error fetching metrics list: {this.state.fetchMetricsError}</Alert>} {fetchMetricsError && <Alert color="danger"><strong>Warning:</strong> Error fetching metrics list: {this.state.fetchMetricsError}</Alert>}
</Col> </Col>
</Row> </Row>
{this.state.panels.map(p => {this.state.panels.map(p =>
@ -188,7 +187,8 @@ class PanelList extends Component<any, PanelListState> {
options={p.options} options={p.options}
onOptionsChanged={(opts: PanelOptions) => this.handleOptionsChanged(p.key, opts)} onOptionsChanged={(opts: PanelOptions) => this.handleOptionsChanged(p.key, opts)}
removePanel={() => this.removePanel(p.key)} removePanel={() => this.removePanel(p.key)}
metricNames={this.state.metricNames} metricNames={metricNames}
pastQueries={pastQueries}
/> />
)} )}
<Button color="primary" className="add-panel-btn" onClick={this.addPanel}>Add Panel</Button> <Button color="primary" className="add-panel-btn" onClick={this.addPanel}>Add Panel</Button>

View File

@ -1,30 +1,17 @@
/** /**
* SanitizeHTML to render HTML, this takes care of sanitizing HTML. * SanitizeHTML to render HTML, this takes care of sanitizing HTML.
*/ */
import React, { PureComponent } from 'react'; import React, { memo } from 'react';
import sanitizeHTML from 'sanitize-html'; import sanitizeHTML from 'sanitize-html';
interface SanitizeHTMLProps { interface SanitizeHTMLProps {
inline: Boolean;
allowedTags: string[]; allowedTags: string[];
children: Element | string; children: string;
tag?: keyof JSX.IntrinsicElements;
} }
class SanitizeHTML extends PureComponent<SanitizeHTMLProps> { const SanitizeHTML = ({ tag: Tag = 'div', children, allowedTags, ...rest }: SanitizeHTMLProps) => (
sanitize = (html: any) => { <Tag {...rest} dangerouslySetInnerHTML={{ __html: sanitizeHTML(children, { allowedTags }) }} />
return sanitizeHTML(html, { );
allowedTags: this.props.allowedTags
});
};
render() { export default memo(SanitizeHTML);
const { inline, children } = this.props;
return inline ? (
<span dangerouslySetInnerHTML={{ __html: this.sanitize(children) }} />
) : (
<div dangerouslySetInnerHTML={{ __html: this.sanitize(children) }} />
);
}
}
export default SanitizeHTML;