Merge pull request #12284 from grafana/davkal/queryfield-refactor

Query field refactorings to support external plugins
This commit is contained in:
David 2018-07-09 11:29:59 +02:00 committed by GitHub
commit 25bcdbcab1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 246 additions and 195 deletions

View File

@ -9,7 +9,7 @@ import { getNextCharacter, getPreviousCousin } from './utils/dom';
import BracesPlugin from './slate-plugins/braces'; import BracesPlugin from './slate-plugins/braces';
import ClearPlugin from './slate-plugins/clear'; import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline'; import NewlinePlugin from './slate-plugins/newline';
import PluginPrism, { configurePrismMetricsTokens } from './slate-plugins/prism/index'; import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
import RunnerPlugin from './slate-plugins/runner'; import RunnerPlugin from './slate-plugins/runner';
import debounce from './utils/debounce'; import debounce from './utils/debounce';
import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus'; import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
@ -17,13 +17,13 @@ import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
import Typeahead from './Typeahead'; import Typeahead from './Typeahead';
const EMPTY_METRIC = ''; const EMPTY_METRIC = '';
const TYPEAHEAD_DEBOUNCE = 300; export const TYPEAHEAD_DEBOUNCE = 300;
function flattenSuggestions(s) { function flattenSuggestions(s) {
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : []; return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
} }
const getInitialValue = query => export const getInitialValue = query =>
Value.fromJSON({ Value.fromJSON({
document: { document: {
nodes: [ nodes: [
@ -45,12 +45,14 @@ const getInitialValue = query =>
}, },
}); });
class Portal extends React.Component { class Portal extends React.Component<any, any> {
node: any; node: any;
constructor(props) { constructor(props) {
super(props); super(props);
const { index = 0, prefix = 'query' } = props;
this.node = document.createElement('div'); this.node = document.createElement('div');
this.node.classList.add('explore-typeahead', `explore-typeahead-${props.index}`); this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
document.body.appendChild(this.node); document.body.appendChild(this.node);
} }
@ -71,12 +73,14 @@ class QueryField extends React.Component<any, any> {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
const { prismDefinition = {}, prismLanguage = 'promql' } = props;
this.plugins = [ this.plugins = [
BracesPlugin(), BracesPlugin(),
ClearPlugin(), ClearPlugin(),
RunnerPlugin({ handler: props.onPressEnter }), RunnerPlugin({ handler: props.onPressEnter }),
NewlinePlugin(), NewlinePlugin(),
PluginPrism(), PluginPrism({ definition: prismDefinition, language: prismLanguage }),
]; ];
this.state = { this.state = {
@ -131,7 +135,8 @@ class QueryField extends React.Component<any, any> {
if (!this.state.metrics) { if (!this.state.metrics) {
return; return;
} }
configurePrismMetricsTokens(this.state.metrics); setPrismTokens(this.props.prismLanguage, 'metrics', this.state.metrics);
// Trigger re-render // Trigger re-render
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
// Bogus edit to trigger highlighting // Bogus edit to trigger highlighting
@ -162,7 +167,7 @@ class QueryField extends React.Component<any, any> {
const selection = window.getSelection(); const selection = window.getSelection();
if (selection.anchorNode) { if (selection.anchorNode) {
const wrapperNode = selection.anchorNode.parentElement; const wrapperNode = selection.anchorNode.parentElement;
const editorNode = wrapperNode.closest('.query-field'); const editorNode = wrapperNode.closest('.slate-query-field');
if (!editorNode || this.state.value.isBlurred) { if (!editorNode || this.state.value.isBlurred) {
// Not inside this editor // Not inside this editor
return; return;
@ -330,20 +335,30 @@ class QueryField extends React.Component<any, any> {
} }
onKeyDown = (event, change) => { onKeyDown = (event, change) => {
if (this.menuEl) {
const { typeaheadIndex, suggestions } = this.state; const { typeaheadIndex, suggestions } = this.state;
switch (event.key) { switch (event.key) {
case 'Escape': { case 'Escape': {
if (this.menuEl) { if (this.menuEl) {
event.preventDefault(); event.preventDefault();
event.stopPropagation();
this.resetTypeahead(); this.resetTypeahead();
return true; return true;
} }
break; break;
} }
case ' ': {
if (event.ctrlKey) {
event.preventDefault();
this.handleTypeahead();
return true;
}
break;
}
case 'Tab': { case 'Tab': {
if (this.menuEl) {
// Dont blur input // Dont blur input
event.preventDefault(); event.preventDefault();
if (!suggestions || suggestions.length === 0) { if (!suggestions || suggestions.length === 0) {
@ -359,18 +374,24 @@ class QueryField extends React.Component<any, any> {
this.applyTypeahead(change, suggestion); this.applyTypeahead(change, suggestion);
return true; return true;
} }
break;
}
case 'ArrowDown': { case 'ArrowDown': {
if (this.menuEl) {
// Select next suggestion // Select next suggestion
event.preventDefault(); event.preventDefault();
this.setState({ typeaheadIndex: typeaheadIndex + 1 }); this.setState({ typeaheadIndex: typeaheadIndex + 1 });
}
break; break;
} }
case 'ArrowUp': { case 'ArrowUp': {
if (this.menuEl) {
// Select previous suggestion // Select previous suggestion
event.preventDefault(); event.preventDefault();
this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) }); this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
}
break; break;
} }
@ -379,7 +400,6 @@ class QueryField extends React.Component<any, any> {
break; break;
} }
} }
}
return undefined; return undefined;
}; };
@ -502,10 +522,17 @@ class QueryField extends React.Component<any, any> {
// Align menu overlay to editor node // Align menu overlay to editor node
if (node) { if (node) {
// Read from DOM
const rect = node.parentElement.getBoundingClientRect(); const rect = node.parentElement.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
// Write DOM
requestAnimationFrame(() => {
menu.style.opacity = 1; menu.style.opacity = 1;
menu.style.top = `${rect.top + window.scrollY + rect.height + 4}px`; menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
menu.style.left = `${rect.left + window.scrollX - 2}px`; menu.style.left = `${rect.left + scrollX - 2}px`;
});
} }
}; };
@ -514,6 +541,7 @@ class QueryField extends React.Component<any, any> {
}; };
renderMenu = () => { renderMenu = () => {
const { portalPrefix } = this.props;
const { suggestions } = this.state; const { suggestions } = this.state;
const hasSuggesstions = suggestions && suggestions.length > 0; const hasSuggesstions = suggestions && suggestions.length > 0;
if (!hasSuggesstions) { if (!hasSuggesstions) {
@ -524,11 +552,13 @@ class QueryField extends React.Component<any, any> {
let selectedIndex = Math.max(this.state.typeaheadIndex, 0); let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
const flattenedSuggestions = flattenSuggestions(suggestions); const flattenedSuggestions = flattenSuggestions(suggestions);
selectedIndex = selectedIndex % flattenedSuggestions.length || 0; selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
const selectedKeys = flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []; const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(
i => (typeof i === 'object' ? i.text : i)
);
// Create typeahead in DOM root so we can later position it absolutely // Create typeahead in DOM root so we can later position it absolutely
return ( return (
<Portal> <Portal prefix={portalPrefix}>
<Typeahead <Typeahead
menuRef={this.menuRef} menuRef={this.menuRef}
selectedItems={selectedKeys} selectedItems={selectedKeys}
@ -541,7 +571,7 @@ class QueryField extends React.Component<any, any> {
render() { render() {
return ( return (
<div className="query-field"> <div className="slate-query-field">
{this.renderMenu()} {this.renderMenu()}
<Editor <Editor
autoCorrect={false} autoCorrect={false}

View File

@ -1,5 +1,6 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import promql from './slate-plugins/prism/promql';
import QueryField from './QueryField'; import QueryField from './QueryField';
class QueryRow extends PureComponent<any, any> { class QueryRow extends PureComponent<any, any> {
@ -55,12 +56,15 @@ class QueryRow extends PureComponent<any, any> {
<i className="fa fa-minus" /> <i className="fa fa-minus" />
</button> </button>
</div> </div>
<div className="query-field-wrapper"> <div className="slate-query-field-wrapper">
<QueryField <QueryField
initialQuery={edited ? null : query} initialQuery={edited ? null : query}
portalPrefix="explore"
onPressEnter={this.handlePressEnter} onPressEnter={this.handlePressEnter}
onQueryChange={this.handleChangeQuery} onQueryChange={this.handleChangeQuery}
placeholder="Enter a PromQL query" placeholder="Enter a PromQL query"
prismLanguage="promql"
prismDefinition={promql}
request={request} request={request}
/> />
</div> </div>

View File

@ -23,12 +23,13 @@ class TypeaheadItem extends React.PureComponent<any, any> {
}; };
render() { render() {
const { isSelected, label, onClickItem } = this.props; const { hint, isSelected, label, onClickItem } = this.props;
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item'; const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
const onClick = () => onClickItem(label); const onClick = () => onClickItem(label);
return ( return (
<li ref={this.getRef} className={className} onClick={onClick}> <li ref={this.getRef} className={className} onClick={onClick}>
{label} {label}
{hint && isSelected ? <div className="typeahead-item-hint">{hint}</div> : null}
</li> </li>
); );
} }
@ -41,9 +42,19 @@ class TypeaheadGroup extends React.PureComponent<any, any> {
<li className="typeahead-group"> <li className="typeahead-group">
<div className="typeahead-group__title">{label}</div> <div className="typeahead-group__title">{label}</div>
<ul className="typeahead-group__list"> <ul className="typeahead-group__list">
{items.map(item => ( {items.map(item => {
<TypeaheadItem key={item} onClickItem={onClickItem} isSelected={selected.indexOf(item) > -1} label={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}
/>
);
})}
</ul> </ul>
</li> </li>
); );

View File

@ -1,16 +1,12 @@
import React from 'react'; import React from 'react';
import Prism from 'prismjs'; import Prism from 'prismjs';
import Promql from './promql';
Prism.languages.promql = Promql;
const TOKEN_MARK = 'prism-token'; const TOKEN_MARK = 'prism-token';
export function configurePrismMetricsTokens(metrics) { export function setPrismTokens(language, field, values, alias = 'variable') {
Prism.languages.promql.metric = { Prism.languages[language][field] = {
alias: 'variable', alias,
pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`), pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
}; };
} }
@ -21,7 +17,12 @@ export function configurePrismMetricsTokens(metrics) {
* (Adapted to handle nested grammar definitions.) * (Adapted to handle nested grammar definitions.)
*/ */
export default function PrismPlugin() { export default function PrismPlugin({ definition, language }) {
if (definition) {
// Don't override exising modified definitions
Prism.languages[language] = Prism.languages[language] || definition;
}
return { return {
/** /**
* Render a Slate mark with appropiate CSS class names * Render a Slate mark with appropiate CSS class names
@ -54,7 +55,7 @@ export default function PrismPlugin() {
const texts = node.getTexts().toArray(); const texts = node.getTexts().toArray();
const tstring = texts.map(t => t.text).join('\n'); const tstring = texts.map(t => t.text).join('\n');
const grammar = Prism.languages.promql; const grammar = Prism.languages[language];
const tokens = Prism.tokenize(tstring, grammar); const tokens = Prism.tokenize(tstring, grammar);
const decorations = []; const decorations = [];
let startText = texts.shift(); let startText = texts.shift();

View File

@ -67,6 +67,7 @@
@import 'components/filter-list'; @import 'components/filter-list';
@import 'components/filter-table'; @import 'components/filter-table';
@import 'components/old_stuff'; @import 'components/old_stuff';
@import 'components/slate_editor';
@import 'components/typeahead'; @import 'components/typeahead';
@import 'components/modals'; @import 'components/modals';
@import 'components/dropdown'; @import 'components/dropdown';

View File

@ -0,0 +1,151 @@
.slate-query-field {
font-size: $font-size-root;
font-family: $font-family-monospace;
height: auto;
}
.slate-query-field-wrapper {
position: relative;
display: inline-block;
padding: 6px 7px 4px;
width: 100%;
cursor: text;
line-height: $line-height-base;
color: $text-color-weak;
background-color: $panel-bg;
background-image: none;
border: $panel-border;
border-radius: $border-radius;
transition: all 0.3s;
}
.slate-typeahead {
.typeahead {
position: absolute;
z-index: auto;
top: -10000px;
left: -10000px;
opacity: 0;
border-radius: $border-radius;
transition: opacity 0.75s;
border: $panel-border;
max-height: calc(66vh);
overflow-y: scroll;
max-width: calc(66%);
overflow-x: hidden;
outline: none;
list-style: none;
background: $panel-bg;
color: $text-color;
transition: opacity 0.4s ease-out;
box-shadow: $typeahead-shadow;
}
.typeahead-group__title {
color: $text-color-weak;
font-size: $font-size-sm;
line-height: $line-height-base;
padding: $input-padding-y $input-padding-x;
}
.typeahead-item {
height: auto;
font-family: $font-family-monospace;
padding: $input-padding-y $input-padding-x;
padding-left: $input-padding-x-lg;
font-size: $font-size-sm;
text-overflow: ellipsis;
overflow: hidden;
z-index: 1;
display: block;
white-space: nowrap;
cursor: pointer;
transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.typeahead-item__selected {
background-color: $typeahead-selected-bg;
color: $typeahead-selected-color;
.typeahead-item-hint {
font-size: $font-size-xs;
color: $text-color;
}
}
}
/* SYNTAX */
.slate-query-field {
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: $text-color-weak;
}
.token.punctuation {
color: $text-color-weak;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.function-name,
.token.constant,
.token.symbol,
.token.deleted {
color: $query-red;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.function,
.token.builtin,
.token.inserted {
color: $query-green;
}
.token.operator,
.token.entity,
.token.url,
.token.variable {
color: $query-purple;
}
.token.atrule,
.token.attr-value,
.token.keyword,
.token.class-name {
color: $query-blue;
}
.token.regex,
.token.important {
color: $query-orange;
}
.token.important {
font-weight: normal;
}
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.namespace {
opacity: 0.7;
}
}

View File

@ -93,150 +93,3 @@
.query-row-tools { .query-row-tools {
width: 4rem; width: 4rem;
} }
.query-field {
font-size: $font-size-root;
font-family: $font-family-monospace;
height: auto;
}
.query-field-wrapper {
position: relative;
display: inline-block;
padding: 6px 7px 4px;
width: 100%;
cursor: text;
line-height: $line-height-base;
color: $text-color-weak;
background-color: $panel-bg;
background-image: none;
border: $panel-border;
border-radius: $border-radius;
transition: all 0.3s;
}
.explore-typeahead {
.typeahead {
position: absolute;
z-index: auto;
top: -10000px;
left: -10000px;
opacity: 0;
border-radius: $border-radius;
transition: opacity 0.75s;
border: $panel-border;
max-height: calc(66vh);
overflow-y: scroll;
max-width: calc(66%);
overflow-x: hidden;
outline: none;
list-style: none;
background: $panel-bg;
color: $text-color;
transition: opacity 0.4s ease-out;
box-shadow: $typeahead-shadow;
}
.typeahead-group__title {
color: $text-color-weak;
font-size: $font-size-sm;
line-height: $line-height-base;
padding: $input-padding-y $input-padding-x;
}
.typeahead-item {
height: auto;
font-family: $font-family-monospace;
padding: $input-padding-y $input-padding-x;
padding-left: $input-padding-x-lg;
font-size: $font-size-sm;
text-overflow: ellipsis;
overflow: hidden;
z-index: 1;
display: block;
white-space: nowrap;
cursor: pointer;
transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.typeahead-item__selected {
background-color: $typeahead-selected-bg;
color: $typeahead-selected-color;
}
}
/* SYNTAX */
.explore {
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: $text-color-weak;
}
.token.punctuation {
color: $text-color-weak;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.function-name,
.token.constant,
.token.symbol,
.token.deleted {
color: $query-red;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.function,
.token.builtin,
.token.inserted {
color: $query-green;
}
.token.operator,
.token.entity,
.token.url,
.token.variable {
color: $query-purple;
}
.token.atrule,
.token.attr-value,
.token.keyword,
.token.class-name {
color: $query-blue;
}
.token.regex,
.token.important {
color: $query-orange;
}
.token.important {
font-weight: normal;
}
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.namespace {
opacity: 0.7;
}
}