Browse Source

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

Query field refactorings to support external plugins
David 7 years ago
parent
commit
25bcdbcab1

+ 64 - 34
public/app/containers/Explore/QueryField.tsx

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

+ 5 - 1
public/app/containers/Explore/QueryRows.tsx

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

+ 15 - 4
public/app/containers/Explore/Typeahead.tsx

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

+ 11 - 10
public/app/containers/Explore/slate-plugins/prism/index.tsx

@@ -1,16 +1,12 @@
 import React from 'react';
 import Prism from 'prismjs';
 
-import Promql from './promql';
-
-Prism.languages.promql = Promql;
-
 const TOKEN_MARK = 'prism-token';
 
-export function configurePrismMetricsTokens(metrics) {
-  Prism.languages.promql.metric = {
-    alias: 'variable',
-    pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
+export function setPrismTokens(language, field, values, alias = 'variable') {
+  Prism.languages[language][field] = {
+    alias,
+    pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
   };
 }
 
@@ -21,7 +17,12 @@ export function configurePrismMetricsTokens(metrics) {
  * (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 {
     /**
      * Render a Slate mark with appropiate CSS class names
@@ -54,7 +55,7 @@ export default function PrismPlugin() {
 
       const texts = node.getTexts().toArray();
       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 decorations = [];
       let startText = texts.shift();

+ 1 - 0
public/sass/_grafana.scss

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

+ 151 - 0
public/sass/components/_slate_editor.scss

@@ -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;
+  }
+}

+ 0 - 147
public/sass/pages/_explore.scss

@@ -93,150 +93,3 @@
 .query-row-tools {
   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;
-  }
-}