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

Explore: highlight typed text in suggestions
This commit is contained in:
Torkel Ödegaard 2018-10-10 18:50:14 +02:00 committed by GitHub
commit 39b25e0596
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 116 additions and 98 deletions

View File

@ -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 {

View File

@ -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 },
]);
});

View File

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

View File

@ -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>
))}

View File

@ -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>

View File

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

View File

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

View File

@ -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>
);

View File

@ -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', () => {

View File

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

View File

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

View File

@ -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 */