Commit 7699451d by David Committed by Alexander Zobnin

Refactor Explore query field (#12643)

* Refactor Explore query field

- extract typeahead field that only contains logic for the typeahead
  mechanics
- renamed QueryField to PromQueryField, a wrapper around TypeaheadField
  that deals with Prometheus-specific concepts
- PromQueryField creates a promql typeahead by providing the handlers
  for producing suggestions, and for applying suggestions
- The `refresher` promise is needed to trigger a render once an async
  action in the wrapper returns.

This is prep work for a composable query field to be used by Explore, as
well as editors in datasource plugins.

* Added typeahead handling tests

- extracted context-to-suggestion logic to make it testable
- kept DOM-dependent parts in main onTypeahead funtion

* simplified error handling in explore query field

* Refactor query suggestions

- use monaco's suggestion types (roughly), see
  https://github.com/Microsoft/monaco-editor/blob/f6fb545/monaco.d.ts#L4208
- suggest functions and metrics in empty field (ctrl+space)
- copy and expand prometheus function docs from prometheus datasource
  (will be migrated back to the datasource in the future)

* Added prop and state types, removed unused cwrp

* Split up suggestion processing for code readability
parent 1db2e869
import React from 'react';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
import PromQueryField from './PromQueryField';
describe('PromQueryField typeahead handling', () => {
const defaultProps = {
request: () => ({ data: { data: [] } }),
};
it('returns default suggestions on emtpty context', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
describe('range suggestions', () => {
it('returns range suggestions in range context', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '1', prefix: '1', wrapperClasses: ['context-range'] });
expect(result.context).toBe('context-range');
expect(result.refresher).toBeUndefined();
expect(result.suggestions).toEqual([
{
items: [{ label: '1m' }, { label: '5m' }, { label: '10m' }, { label: '30m' }, { label: '1h' }],
label: 'Range vector',
},
]);
});
});
describe('metric suggestions', () => {
it('returns metrics suggestions by default', () => {
const instance = shallow(
<PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
).instance() as PromQueryField;
const result = instance.getTypeahead({ text: 'a', prefix: 'a', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
it('returns default suggestions after a binary operator', () => {
const instance = shallow(
<PromQueryField {...defaultProps} metrics={['foo', 'bar']} />
).instance() as PromQueryField;
const result = instance.getTypeahead({ text: '*', prefix: '', wrapperClasses: [] });
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
});
});
describe('label suggestions', () => {
it('returns default label suggestions on label context and no metric', () => {
const instance = shallow(<PromQueryField {...defaultProps} />).instance() as PromQueryField;
const result = instance.getTypeahead({ text: 'j', prefix: 'j', wrapperClasses: ['context-labels'] });
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
});
it('returns label suggestions on label context and metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
).instance() as PromQueryField;
const result = instance.getTypeahead({
text: 'job',
prefix: 'job',
wrapperClasses: ['context-labels'],
metric: 'foo',
});
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
it('returns a refresher on label context and unavailable metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
).instance() as PromQueryField;
const result = instance.getTypeahead({
text: 'job',
prefix: 'job',
wrapperClasses: ['context-labels'],
metric: 'xxx',
});
expect(result.context).toBeUndefined();
expect(result.refresher).toBeInstanceOf(Promise);
expect(result.suggestions).toEqual([]);
});
it('returns label values on label context when given a metric and a label key', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} labelValues={{ foo: { bar: ['baz'] } }} />
).instance() as PromQueryField;
const result = instance.getTypeahead({
text: '=ba',
prefix: 'ba',
wrapperClasses: ['context-labels'],
metric: 'foo',
labelKey: 'bar',
});
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values' }]);
});
it('returns label suggestions on aggregation context and metric', () => {
const instance = shallow(
<PromQueryField {...defaultProps} labelKeys={{ foo: ['bar'] }} />
).instance() as PromQueryField;
const result = instance.getTypeahead({
text: 'job',
prefix: 'job',
wrapperClasses: ['context-aggregation'],
metric: 'foo',
});
expect(result.context).toBe('context-aggregation');
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
});
});
});
import React, { PureComponent } from 'react';
import promql from './slate-plugins/prism/promql';
import QueryField from './QueryField';
import QueryField from './PromQueryField';
class QueryRow extends PureComponent<any, any> {
constructor(props) {
......@@ -62,9 +61,6 @@ class QueryRow extends PureComponent<any, any> {
portalPrefix="explore"
onPressEnter={this.handlePressEnter}
onQueryChange={this.handleChangeQuery}
placeholder="Enter a PromQL query"
prismLanguage="promql"
prismDefinition={promql}
request={request}
/>
</div>
......
import React from 'react';
function scrollIntoView(el) {
import { Suggestion, SuggestionGroup } from './QueryField';
function scrollIntoView(el: HTMLElement) {
if (!el || !el.offsetParent) {
return;
}
const container = el.offsetParent;
const container = el.offsetParent as HTMLElement;
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
container.scrollTop = el.offsetTop - container.offsetTop;
}
}
class TypeaheadItem extends React.PureComponent<any, any> {
el: any;
interface TypeaheadItemProps {
isSelected: boolean;
item: Suggestion;
onClickItem: (Suggestion) => void;
}
class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
el: HTMLElement;
componentDidUpdate(prevProps) {
if (this.props.isSelected && !prevProps.isSelected) {
scrollIntoView(this.el);
......@@ -22,20 +31,30 @@ class TypeaheadItem extends React.PureComponent<any, any> {
this.el = el;
};
onClick = () => {
this.props.onClickItem(this.props.item);
};
render() {
const { hint, isSelected, label, onClickItem } = this.props;
const { isSelected, item } = this.props;
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
const onClick = () => onClickItem(label);
return (
<li ref={this.getRef} className={className} onClick={onClick}>
{label}
{hint && isSelected ? <div className="typeahead-item-hint">{hint}</div> : null}
<li ref={this.getRef} className={className} onClick={this.onClick}>
{item.detail || item.label}
{item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
</li>
);
}
}
class TypeaheadGroup extends React.PureComponent<any, any> {
interface TypeaheadGroupProps {
items: Suggestion[];
label: string;
onClickItem: (Suggestion) => void;
selected: Suggestion;
}
class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
render() {
const { items, label, selected, onClickItem } = this.props;
return (
......@@ -43,16 +62,8 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
<div className="typeahead-group__title">{label}</div>
<ul className="typeahead-group__list">
{items.map(item => {
const text = typeof item === 'object' ? item.text : item;
const label = typeof item === 'object' ? item.display || item.text : item;
return (
<TypeaheadItem
key={text}
onClickItem={onClickItem}
isSelected={selected.indexOf(text) > -1}
hint={item.hint}
label={label}
/>
<TypeaheadItem key={item.label} onClickItem={onClickItem} isSelected={selected === item} item={item} />
);
})}
</ul>
......@@ -61,13 +72,19 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
}
}
class Typeahead extends React.PureComponent<any, any> {
interface TypeaheadProps {
groupedItems: SuggestionGroup[];
menuRef: any;
selectedItem: Suggestion | null;
onClickItem: (Suggestion) => void;
}
class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
render() {
const { groupedItems, menuRef, selectedItems, onClickItem } = this.props;
const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
return (
<ul className="typeahead" ref={menuRef}>
{groupedItems.map(g => (
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} />
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItem} {...g} />
))}
</ul>
);
......
......@@ -71,6 +71,7 @@
.typeahead-item-hint {
font-size: $font-size-xs;
color: $text-color;
white-space: normal;
}
}
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment