Procházet zdrojové kódy

Merge pull request #13536 from grafana/davkal/explore-text-match

Explore: highlight typed text in suggestions
Torkel Ödegaard před 7 roky
rodič
revize
39b25e0596

+ 2 - 2
public/app/core/logs_model.ts

@@ -11,7 +11,7 @@ export enum LogLevel {
 export interface LogSearchMatch {
   start: number;
   length: number;
-  text?: string;
+  text: string;
 }
 
 export interface LogRow {
@@ -21,7 +21,7 @@ export interface LogRow {
   timestamp: string;
   timeFromNow: string;
   timeLocal: string;
-  searchMatches?: LogSearchMatch[];
+  searchWords?: string[];
 }
 
 export interface LogsModel {

+ 24 - 0
public/app/core/utils/text.test.ts

@@ -0,0 +1,24 @@
+import { findMatchesInText } from './text';
+
+describe('findMatchesInText()', () => {
+  it('gets no matches for when search and or line are empty', () => {
+    expect(findMatchesInText('', '')).toEqual([]);
+    expect(findMatchesInText('foo', '')).toEqual([]);
+    expect(findMatchesInText('', 'foo')).toEqual([]);
+  });
+
+  it('gets no matches for unmatched search string', () => {
+    expect(findMatchesInText('foo', 'bar')).toEqual([]);
+  });
+
+  it('gets matches for matched search string', () => {
+    expect(findMatchesInText('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo', end: 3 }]);
+    expect(findMatchesInText(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo', end: 4 }]);
+  });
+
+  expect(findMatchesInText(' foo foo bar ', 'foo|bar')).toEqual([
+    { length: 3, start: 1, text: 'foo', end: 4 },
+    { length: 3, start: 5, text: 'foo', end: 8 },
+    { length: 3, start: 9, text: 'bar', end: 12 },
+  ]);
+});

+ 32 - 0
public/app/core/utils/text.ts

@@ -0,0 +1,32 @@
+import { TextMatch } from 'app/types/explore';
+
+/**
+ * Adapt findMatchesInText for react-highlight-words findChunks handler.
+ * See https://github.com/bvaughn/react-highlight-words#props
+ */
+export function findHighlightChunksInText({ searchWords, textToHighlight }) {
+  return findMatchesInText(textToHighlight, searchWords.join(' '));
+}
+
+/**
+ * Returns a list of substring regexp matches.
+ */
+export function findMatchesInText(haystack: string, needle: string): TextMatch[] {
+  // Empty search can send re.exec() into infinite loop, exit early
+  if (!haystack || !needle) {
+    return [];
+  }
+  const regexp = new RegExp(`(?:${needle})`, 'g');
+  const matches = [];
+  let match = regexp.exec(haystack);
+  while (match) {
+    matches.push({
+      text: match[0],
+      start: match.index,
+      length: match[0].length,
+      end: match.index + match[0].length,
+    });
+    match = regexp.exec(haystack);
+  }
+  return matches;
+}

+ 10 - 30
public/app/features/explore/Logs.tsx

@@ -1,6 +1,8 @@
 import React, { Fragment, PureComponent } from 'react';
+import Highlighter from 'react-highlight-words';
 
-import { LogsModel, LogRow } from 'app/core/logs_model';
+import { LogsModel } from 'app/core/logs_model';
+import { findHighlightChunksInText } from 'app/core/utils/text';
 
 interface LogsProps {
   className?: string;
@@ -10,34 +12,7 @@ interface LogsProps {
 
 const EXAMPLE_QUERY = '{job="default/prometheus"}';
 
-const Entry: React.SFC<LogRow> = props => {
-  const { entry, searchMatches } = props;
-  if (searchMatches && searchMatches.length > 0) {
-    let lastMatchEnd = 0;
-    const spans = searchMatches.reduce((acc, match, i) => {
-      // Insert non-match
-      if (match.start !== lastMatchEnd) {
-        acc.push(<>{entry.slice(lastMatchEnd, match.start)}</>);
-      }
-      // Match
-      acc.push(
-        <span className="logs-row-match-highlight" title={`Matching expression: ${match.text}`}>
-          {entry.substr(match.start, match.length)}
-        </span>
-      );
-      lastMatchEnd = match.start + match.length;
-      // Non-matching end
-      if (i === searchMatches.length - 1) {
-        acc.push(<>{entry.slice(lastMatchEnd)}</>);
-      }
-      return acc;
-    }, []);
-    return <>{spans}</>;
-  }
-  return <>{props.entry}</>;
-};
-
-export default class Logs extends PureComponent<LogsProps, any> {
+export default class Logs extends PureComponent<LogsProps, {}> {
   render() {
     const { className = '', data } = this.props;
     const hasData = data && data.rows && data.rows.length > 0;
@@ -50,7 +25,12 @@ export default class Logs extends PureComponent<LogsProps, any> {
                 <div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
                 <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
                 <div>
-                  <Entry {...row} />
+                  <Highlighter
+                    textToHighlight={row.entry}
+                    searchWords={row.searchWords}
+                    findChunks={findHighlightChunksInText}
+                    highlightClassName="logs-row-match-highlight"
+                  />
                 </div>
               </Fragment>
             ))}

+ 6 - 6
public/app/features/explore/PromQueryField.tsx

@@ -145,7 +145,7 @@ interface PromQueryFieldProps {
   onClickHintFix?: (action: any) => void;
   onPressEnter?: () => void;
   onQueryChange?: (value: string, override?: boolean) => void;
-  portalPrefix?: string;
+  portalOrigin?: string;
   request?: (url: string) => any;
   supportsLogs?: boolean; // To be removed after Logging gets its own query field
 }
@@ -571,10 +571,10 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
               <button className="btn navbar-button navbar-button--tight">Log labels</button>
             </Cascader>
           ) : (
-            <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
-              <button className="btn navbar-button navbar-button--tight">Metrics</button>
-            </Cascader>
-          )}
+              <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
+                <button className="btn navbar-button navbar-button--tight">Metrics</button>
+              </Cascader>
+            )}
         </div>
         <div className="prom-query-field-wrapper">
           <div className="slate-query-field-wrapper">
@@ -586,7 +586,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
               onWillApplySuggestion={willApplySuggestion}
               onValueChanged={this.onChangeQuery}
               placeholder="Enter a PromQL query"
-              portalPrefix="prometheus"
+              portalOrigin="prometheus"
               syntaxLoaded={syntaxLoaded}
             />
           </div>

+ 8 - 7
public/app/features/explore/QueryField.tsx

@@ -104,7 +104,7 @@ interface TypeaheadFieldProps {
   onValueChanged?: (value: Value) => void;
   onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
   placeholder?: string;
-  portalPrefix?: string;
+  portalOrigin?: string;
   syntax?: string;
   syntaxLoaded?: boolean;
 }
@@ -459,8 +459,8 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
   };
 
   renderMenu = () => {
-    const { portalPrefix } = this.props;
-    const { suggestions, typeaheadIndex } = this.state;
+    const { portalOrigin } = this.props;
+    const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state;
     if (!hasSuggestions(suggestions)) {
       return null;
     }
@@ -469,11 +469,12 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
 
     // Create typeahead in DOM root so we can later position it absolutely
     return (
-      <Portal prefix={portalPrefix}>
+      <Portal origin={portalOrigin}>
         <Typeahead
           menuRef={this.menuRef}
           selectedItem={selectedItem}
           onClickItem={this.onClickMenu}
+          prefix={typeaheadPrefix}
           groupedItems={suggestions}
         />
       </Portal>
@@ -500,14 +501,14 @@ class QueryField extends React.PureComponent<TypeaheadFieldProps, TypeaheadField
   }
 }
 
-class Portal extends React.PureComponent<{ index?: number; prefix: string }, {}> {
+class Portal extends React.PureComponent<{ index?: number; origin: string }, {}> {
   node: HTMLElement;
 
   constructor(props) {
     super(props);
-    const { index = 0, prefix = 'query' } = props;
+    const { index = 0, origin = 'query' } = props;
     this.node = document.createElement('div');
-    this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
+    this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
     document.body.appendChild(this.node);
   }
 

+ 0 - 1
public/app/features/explore/QueryRows.tsx

@@ -53,7 +53,6 @@ class QueryRow extends PureComponent<any, {}> {
             hint={queryHint}
             initialQuery={query}
             history={history}
-            portalPrefix="explore"
             onClickHintFix={this.onClickHintFix}
             onPressEnter={this.onPressEnter}
             onQueryChange={this.onChangeQuery}

+ 17 - 6
public/app/features/explore/Typeahead.tsx

@@ -1,4 +1,5 @@
 import React from 'react';
+import Highlighter from 'react-highlight-words';
 
 import { Suggestion, SuggestionGroup } from './QueryField';
 
@@ -16,6 +17,7 @@ interface TypeaheadItemProps {
   isSelected: boolean;
   item: Suggestion;
   onClickItem: (Suggestion) => void;
+  prefix?: string;
 }
 
 class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
@@ -38,11 +40,12 @@ class TypeaheadItem extends React.PureComponent<TypeaheadItemProps, {}> {
   };
 
   render() {
-    const { isSelected, item } = this.props;
+    const { isSelected, item, prefix } = this.props;
     const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
+    const { label } = item;
     return (
       <li ref={this.getRef} className={className} onClick={this.onClick}>
-        {item.detail || item.label}
+        <Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName="typeahead-match" />
         {item.documentation && isSelected ? <div className="typeahead-item-hint">{item.documentation}</div> : null}
       </li>
     );
@@ -54,18 +57,25 @@ interface TypeaheadGroupProps {
   label: string;
   onClickItem: (Suggestion) => void;
   selected: Suggestion;
+  prefix?: string;
 }
 
 class TypeaheadGroup extends React.PureComponent<TypeaheadGroupProps, {}> {
   render() {
-    const { items, label, selected, onClickItem } = this.props;
+    const { items, label, selected, onClickItem, prefix } = this.props;
     return (
       <li className="typeahead-group">
         <div className="typeahead-group__title">{label}</div>
         <ul className="typeahead-group__list">
           {items.map(item => {
             return (
-              <TypeaheadItem key={item.label} onClickItem={onClickItem} isSelected={selected === item} item={item} />
+              <TypeaheadItem
+                key={item.label}
+                onClickItem={onClickItem}
+                isSelected={selected === item}
+                item={item}
+                prefix={prefix}
+              />
             );
           })}
         </ul>
@@ -79,14 +89,15 @@ interface TypeaheadProps {
   menuRef: any;
   selectedItem: Suggestion | null;
   onClickItem: (Suggestion) => void;
+  prefix?: string;
 }
 class Typeahead extends React.PureComponent<TypeaheadProps, {}> {
   render() {
-    const { groupedItems, menuRef, selectedItem, onClickItem } = this.props;
+    const { groupedItems, menuRef, selectedItem, onClickItem, prefix } = this.props;
     return (
       <ul className="typeahead" ref={menuRef}>
         {groupedItems.map(g => (
-          <TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItem} {...g} />
+          <TypeaheadGroup key={g.label} onClickItem={onClickItem} prefix={prefix} selected={selectedItem} {...g} />
         ))}
       </ul>
     );

+ 1 - 24
public/app/plugins/datasource/logging/result_transformer.test.ts

@@ -1,29 +1,6 @@
 import { LogLevel } from 'app/core/logs_model';
 
-import { getLogLevel, getSearchMatches } from './result_transformer';
-
-describe('getSearchMatches()', () => {
-  it('gets no matches for when search and or line are empty', () => {
-    expect(getSearchMatches('', '')).toEqual([]);
-    expect(getSearchMatches('foo', '')).toEqual([]);
-    expect(getSearchMatches('', 'foo')).toEqual([]);
-  });
-
-  it('gets no matches for unmatched search string', () => {
-    expect(getSearchMatches('foo', 'bar')).toEqual([]);
-  });
-
-  it('gets matches for matched search string', () => {
-    expect(getSearchMatches('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo' }]);
-    expect(getSearchMatches(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo' }]);
-  });
-
-  expect(getSearchMatches(' foo foo bar ', 'foo|bar')).toEqual([
-    { length: 3, start: 1, text: 'foo' },
-    { length: 3, start: 5, text: 'foo' },
-    { length: 3, start: 9, text: 'bar' },
-  ]);
-});
+import { getLogLevel } from './result_transformer';
 
 describe('getLoglevel()', () => {
   it('returns no log level on empty line', () => {

+ 1 - 21
public/app/plugins/datasource/logging/result_transformer.ts

@@ -19,25 +19,6 @@ export function getLogLevel(line: string): LogLevel {
   return level;
 }
 
-export function getSearchMatches(line: string, search: string) {
-  // Empty search can send re.exec() into infinite loop, exit early
-  if (!line || !search) {
-    return [];
-  }
-  const regexp = new RegExp(`(?:${search})`, 'g');
-  const matches = [];
-  let match = regexp.exec(line);
-  while (match) {
-    matches.push({
-      text: match[0],
-      start: match.index,
-      length: match[0].length,
-    });
-    match = regexp.exec(line);
-  }
-  return matches;
-}
-
 export function processEntry(entry: { line: string; timestamp: string }, stream): LogRow {
   const { line, timestamp } = entry;
   const { labels } = stream;
@@ -45,16 +26,15 @@ export function processEntry(entry: { line: string; timestamp: string }, stream)
   const time = moment(timestamp);
   const timeFromNow = time.fromNow();
   const timeLocal = time.format('YYYY-MM-DD HH:mm:ss');
-  const searchMatches = getSearchMatches(line, stream.search);
   const logLevel = getLogLevel(line);
 
   return {
     key,
     logLevel,
-    searchMatches,
     timeFromNow,
     timeLocal,
     entry: line,
+    searchWords: [stream.search],
     timestamp: timestamp,
   };
 }

+ 7 - 0
public/app/types/explore.ts

@@ -13,6 +13,13 @@ export interface Query {
   key?: string;
 }
 
+export interface TextMatch {
+  text: string;
+  start: number;
+  length: number;
+  end: number;
+}
+
 export interface ExploreState {
   datasource: any;
   datasourceError: any;

+ 8 - 1
public/sass/components/_slate_editor.scss

@@ -66,7 +66,6 @@
 
   .typeahead-item__selected {
     background-color: $typeahead-selected-bg;
-    color: $typeahead-selected-color;
 
     .typeahead-item-hint {
       font-size: $font-size-xs;
@@ -74,6 +73,14 @@
       white-space: normal;
     }
   }
+
+  .typeahead-match {
+    color: $typeahead-selected-color;
+    border-bottom: 1px solid $typeahead-selected-color;
+    // Undoing mark styling
+    padding: inherit;
+    background: inherit;
+  }
 }
 
 /* SYNTAX */