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>
This commit is contained in:
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%;
left: 56px;
float: left;
padding: .5rem 1px .5rem 1px;
margin: -5px;
list-style: none;
}
@ -90,6 +89,14 @@ button.execute-btn {
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 {
margin-bottom: 10px;
}

View File

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

View File

@ -5,6 +5,10 @@ import {
InputGroupAddon,
InputGroupText,
Input,
ListGroup,
ListGroupItem,
Card,
CardHeader,
} from 'reactstrap';
import Downshift, { ControllerStateAndHelpers } from 'downshift';
@ -16,7 +20,7 @@ import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons';
interface ExpressionInputProps {
value: string;
metricNames: string[];
autocompleteSections: { [key: string]: string[] };
executeQuery: (expr: string) => void;
loading: boolean;
}
@ -27,7 +31,6 @@ interface ExpressionInputState {
}
class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputState> {
private prevNoMatchValue: string | null = null;
private exprInputRef = React.createRef<HTMLInputElement>();
constructor(props: ExpressionInputProps) {
@ -42,7 +45,6 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
this.setHeight();
}
setHeight = () => {
const { offsetHeight, clientHeight, scrollHeight } = this.exprInputRef.current!;
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) => {
this.setState({ value, height: 'auto' }, this.setHeight)
this.setState({ value, height: 'auto' }, this.setHeight);
};
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>) => {
const { inputValue } = downshift
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, {
getSearchMatches = (input: string, expressions: string[]) => {
return fuzzy.filter(input.replace(/ /g, ''), expressions, {
pre: "<strong>",
post: "</strong>",
});
}
if (matches.length === 0) {
this.prevNoMatchValue = inputValue;
setTimeout(downshift.closeMenu);
createAutocompleteSection = (downshift: ControllerStateAndHelpers<any>) => {
const { inputValue = '', closeMenu, highlightedIndex } = downshift;
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 (
<ul className="autosuggest-dropdown" {...downshift.getMenuProps()}>
{
matches
.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>
<div {...downshift.getMenuProps()} className="autosuggest-dropdown">
{ sections }
</div>
);
}
@ -121,8 +130,8 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
const { value, height } = this.state;
return (
<Downshift
onChange={this.handleDropdownSelection}
inputValue={value}
onSelect={this.handleDropdownSelection}
>
{(downshift) => (
<div>
@ -179,7 +188,7 @@ class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputSta
</Button>
</InputGroupAddon>
</InputGroup>
{downshift.isOpen && this.renderAutosuggest(downshift)}
{downshift.isOpen && this.createAutocompleteSection(downshift)}
</div>
)}
</Downshift>

View File

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

View File

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

View File

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