From b644ea9e6e89e2d5d3f9401a3e163ceeac0502e5 Mon Sep 17 00:00:00 2001 From: Connor Lindsey Date: Mon, 26 Jul 2021 08:04:03 -0600 Subject: [PATCH] Loki: Add fuzzy search to label browser (#36864) --- .../src/components/BrowserLabel}/Label.tsx | 67 +++++++--- packages/grafana-ui/src/components/index.ts | 1 + .../{slate-plugins => utils}/fuzzy.test.ts | 11 ++ .../src/{slate-plugins => utils}/fuzzy.ts | 5 + packages/grafana-ui/src/utils/index.ts | 1 + .../grafana-ui/src/utils/searchFunctions.ts | 2 +- .../datasource/loki/components/LokiLabel.tsx | 125 ------------------ .../loki/components/LokiLabelBrowser.test.tsx | 4 +- .../loki/components/LokiLabelBrowser.tsx | 62 +++++++-- .../components/PrometheusMetricsBrowser.tsx | 14 +- scripts/ci-reference-docs-lint.sh | 2 +- 11 files changed, 130 insertions(+), 164 deletions(-) rename {public/app/plugins/datasource/prometheus/components => packages/grafana-ui/src/components/BrowserLabel}/Label.tsx (67%) rename packages/grafana-ui/src/{slate-plugins => utils}/fuzzy.test.ts (88%) rename packages/grafana-ui/src/{slate-plugins => utils}/fuzzy.ts (91%) delete mode 100644 public/app/plugins/datasource/loki/components/LokiLabel.tsx diff --git a/public/app/plugins/datasource/prometheus/components/Label.tsx b/packages/grafana-ui/src/components/BrowserLabel/Label.tsx similarity index 67% rename from public/app/plugins/datasource/prometheus/components/Label.tsx rename to packages/grafana-ui/src/components/BrowserLabel/Label.tsx index 73aee5da58c..8132c891b5a 100644 --- a/public/app/plugins/datasource/prometheus/components/Label.tsx +++ b/packages/grafana-ui/src/components/BrowserLabel/Label.tsx @@ -1,16 +1,15 @@ -import React, { forwardRef, HTMLAttributes } from 'react'; +import React, { forwardRef, HTMLAttributes, useCallback } from 'react'; import { cx, css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { useTheme2 } from '@grafana/ui'; +import { useTheme2 } from '../../themes'; // @ts-ignore import Highlighter from 'react-highlight-words'; +import { PartialHighlighter } from '../Typeahead/PartialHighlighter'; +import { HighlightPart } from '../../types'; -/** - * @public - */ -export type OnLabelClick = (name: string, value: string | undefined, event: React.MouseEvent) => void; +type OnLabelClick = (name: string, value: string | undefined, event: React.MouseEvent) => void; -export interface Props extends Omit, 'onClick'> { +interface Props extends Omit, 'onClick'> { name: string; active?: boolean; loading?: boolean; @@ -18,23 +17,45 @@ export interface Props extends Omit, 'onClick'> { value?: string; facets?: number; title?: string; + highlightParts?: HighlightPart[]; onClick?: OnLabelClick; } /** - * TODO #33976: Create a common, shared component with public/app/plugins/datasource/loki/components/LokiLabel.tsx + * @internal */ export const Label = forwardRef( - ({ name, value, hidden, facets, onClick, className, loading, searchTerm, active, style, title, ...rest }, ref) => { + ( + { + name, + value, + hidden, + facets, + onClick, + className, + loading, + searchTerm, + active, + style, + title, + highlightParts, + ...rest + }, + ref + ) => { const theme = useTheme2(); const styles = getLabelStyles(theme); const searchWords = searchTerm ? [searchTerm] : []; - const onLabelClick = (event: React.MouseEvent) => { - if (onClick && !hidden) { - onClick(name, value, event); - } - }; + const onLabelClick = useCallback( + (event: React.MouseEvent) => { + if (onClick && !hidden) { + onClick(name, value, event); + } + }, + [onClick, name, hidden, value] + ); + // Using this component for labels and label values. If value is given use value for display text. let text = value || name; if (facets) { @@ -60,12 +81,16 @@ export const Label = forwardRef( )} {...rest} > - + {highlightParts !== undefined ? ( + + ) : ( + + )} ); } @@ -75,11 +100,11 @@ Label.displayName = 'Label'; const getLabelStyles = (theme: GrafanaTheme2) => ({ base: css` + display: inline-block; cursor: pointer; font-size: ${theme.typography.size.sm}; line-height: ${theme.typography.bodySmall.lineHeight}; background-color: ${theme.colors.background.secondary}; - vertical-align: baseline; color: ${theme.colors.text}; white-space: nowrap; text-shadow: none; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 76bc5dcf55e..7d61b2523f4 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -255,3 +255,4 @@ export { preparePlotFrame } from './GraphNG/utils'; export { GraphNGLegendEvent } from './GraphNG/types'; export * from './PanelChrome/types'; export { EmotionPerfTest } from './ThemeDemos/EmotionPerfTest'; +export { Label as BrowserLabel } from './BrowserLabel/Label'; diff --git a/packages/grafana-ui/src/slate-plugins/fuzzy.test.ts b/packages/grafana-ui/src/utils/fuzzy.test.ts similarity index 88% rename from packages/grafana-ui/src/slate-plugins/fuzzy.test.ts rename to packages/grafana-ui/src/utils/fuzzy.test.ts index c32a848edd7..740cea2a8bb 100644 --- a/packages/grafana-ui/src/slate-plugins/fuzzy.test.ts +++ b/packages/grafana-ui/src/utils/fuzzy.test.ts @@ -76,4 +76,15 @@ describe('Fuzzy search', () => { found: true, }); }); + + it('ignores whitespace in needle', () => { + expect(fuzzyMatch('bbaarr_bar_bbarr', 'bb bar')).toEqual({ + ranges: [ + { start: 0, end: 1 }, + { start: 7, end: 9 }, + ], + distance: 5, + found: true, + }); + }); }); diff --git a/packages/grafana-ui/src/slate-plugins/fuzzy.ts b/packages/grafana-ui/src/utils/fuzzy.ts similarity index 91% rename from packages/grafana-ui/src/slate-plugins/fuzzy.ts rename to packages/grafana-ui/src/utils/fuzzy.ts index 030831defaa..69d47d5167f 100644 --- a/packages/grafana-ui/src/slate-plugins/fuzzy.ts +++ b/packages/grafana-ui/src/utils/fuzzy.ts @@ -20,10 +20,15 @@ type FuzzyMatch = { * * @param stack - main text to be searched * @param needle - partial text to find in the stack + * + * @internal */ export function fuzzyMatch(stack: string, needle: string): FuzzyMatch { let distance = 0, searchIndex = stack.indexOf(needle); + // Remove whitespace from needle as a temporary solution to treat separate string + // queries as 'AND' + needle = needle.replace(/\s/g, ''); const ranges: HighlightPart[] = []; diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 109951e5c4a..52cbd388c46 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -16,3 +16,4 @@ export { renderOrCallToRender } from './renderOrCallToRender'; export { createLogger } from './logger'; export { attachDebugger } from './debug'; export * from './nodeGraph'; +export { fuzzyMatch } from './fuzzy'; diff --git a/packages/grafana-ui/src/utils/searchFunctions.ts b/packages/grafana-ui/src/utils/searchFunctions.ts index 9ceb1fd89be..677b2d06454 100644 --- a/packages/grafana-ui/src/utils/searchFunctions.ts +++ b/packages/grafana-ui/src/utils/searchFunctions.ts @@ -1,5 +1,5 @@ import { CompletionItem, SearchFunction } from '../types'; -import { fuzzyMatch } from '../slate-plugins/fuzzy'; +import { fuzzyMatch } from './fuzzy'; /** * List of auto-complete search function used by SuggestionsPlugin.handleTypeahead() diff --git a/public/app/plugins/datasource/loki/components/LokiLabel.tsx b/public/app/plugins/datasource/loki/components/LokiLabel.tsx deleted file mode 100644 index 4340740d92c..00000000000 --- a/public/app/plugins/datasource/loki/components/LokiLabel.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { forwardRef, HTMLAttributes } from 'react'; -import { cx, css } from '@emotion/css'; -import { GrafanaTheme2 } from '@grafana/data'; -import { useTheme2 } from '@grafana/ui'; -// @ts-ignore -import Highlighter from 'react-highlight-words'; - -/** - * @public - */ -export type OnLabelClick = (name: string, value: string | undefined, event: React.MouseEvent) => void; - -export interface Props extends Omit, 'onClick'> { - name: string; - active?: boolean; - loading?: boolean; - searchTerm?: string; - value?: string; - facets?: number; - onClick?: OnLabelClick; -} - -export const LokiLabel = forwardRef( - ({ name, value, hidden, facets, onClick, className, loading, searchTerm, active, style, ...rest }, ref) => { - const theme = useTheme2(); - const styles = getLabelStyles(theme); - const searchWords = searchTerm ? [searchTerm] : []; - - const onLabelClick = (event: React.MouseEvent) => { - if (onClick && !hidden) { - onClick(name, value, event); - } - }; - // Using this component for labels and label values. If value is given use value for display text. - let text = value || name; - if (facets) { - text = `${text} (${facets})`; - } - - return ( - - ); - } -); - -LokiLabel.displayName = 'LokiLabel'; - -const getLabelStyles = (theme: GrafanaTheme2) => ({ - base: css` - cursor: pointer; - font-size: ${theme.typography.size.sm}; - line-height: ${theme.typography.bodySmall.lineHeight}; - background-color: ${theme.colors.background.secondary}; - vertical-align: baseline; - color: ${theme.colors.text}; - white-space: nowrap; - text-shadow: none; - padding: ${theme.spacing(0.5)}; - border-radius: ${theme.shape.borderRadius()}; - margin-right: ${theme.spacing(1)}; - margin-bottom: ${theme.spacing(0.5)}; - `, - loading: css` - font-weight: ${theme.typography.fontWeightMedium}; - background-color: ${theme.colors.primary.shade}; - color: ${theme.colors.text.primary}; - animation: pulse 3s ease-out 0s infinite normal forwards; - @keyframes pulse { - 0% { - color: ${theme.colors.text.primary}; - } - 50% { - color: ${theme.colors.text.secondary}; - } - 100% { - color: ${theme.colors.text.disabled}; - } - } - `, - active: css` - font-weight: ${theme.typography.fontWeightMedium}; - background-color: ${theme.colors.primary.main}; - color: ${theme.colors.primary.contrastText}; - `, - matchHighLight: css` - background: inherit; - color: ${theme.colors.primary.text}; - background-color: ${theme.colors.primary.transparent}; - `, - hidden: css` - opacity: 0.6; - cursor: default; - border: 1px solid transparent; - `, - hover: css` - &:hover { - opacity: 0.85; - cursor: pointer; - } - `, -}); diff --git a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx index f7714f2ff7d..fe9ce1b4e02 100644 --- a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx +++ b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx @@ -246,8 +246,8 @@ describe('LokiLabelBrowser', () => { await screen.findByLabelText('Values for label2'); expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); // Typing '1' to filter for values - userEvent.type(screen.getByLabelText('Filter expression for values'), '1'); - expect(screen.getByLabelText('Filter expression for values')).toHaveValue('1'); + userEvent.type(screen.getByLabelText('Filter expression for values'), 'val1'); + expect(screen.getByLabelText('Filter expression for values')).toHaveValue('val1'); expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument(); expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(3); }); diff --git a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx index 15e7f74e9c4..95c949b423a 100644 --- a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx +++ b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx @@ -1,19 +1,29 @@ import React, { ChangeEvent } from 'react'; -import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, withTheme2 } from '@grafana/ui'; +import { + Button, + HighlightPart, + HorizontalGroup, + Input, + Label, + LoadingPlaceholder, + withTheme2, + BrowserLabel as LokiLabel, + fuzzyMatch, +} from '@grafana/ui'; import LokiLanguageProvider from '../language_provider'; import PromQlLanguageProvider from '../../prometheus/language_provider'; import { css, cx } from '@emotion/css'; import store from 'app/core/store'; import { FixedSizeList } from 'react-window'; - import { GrafanaTheme2 } from '@grafana/data'; -import { LokiLabel } from './LokiLabel'; +import { sortBy } from 'lodash'; // Hard limit on labels to render const MAX_LABEL_COUNT = 1000; const MAX_VALUE_COUNT = 10000; const MAX_AUTO_SELECT = 4; const EMPTY_SELECTOR = '{}'; + export const LAST_USED_LABELS_KEY = 'grafana.datasources.loki.browser.labels'; export interface BrowserProps { @@ -36,6 +46,8 @@ interface BrowserState { interface FacettableValue { name: string; selected?: boolean; + highlightParts?: HighlightPart[]; + order?: number; } export interface SelectableLabel { @@ -378,15 +390,42 @@ export class UnthemedLokiLabelBrowser extends React.Component; } const styles = getStyles(theme); - let selectedLabels = labels.filter((label) => label.selected && label.values); - if (searchTerm) { - selectedLabels = selectedLabels.map((label) => ({ - ...label, - values: label.values?.filter((value) => value.selected || value.name.includes(searchTerm)), - })); - } const selector = buildSelector(this.state.labels); const empty = selector === EMPTY_SELECTOR; + + let selectedLabels = labels.filter((label) => label.selected && label.values); + if (searchTerm) { + selectedLabels = selectedLabels.map((label) => { + const searchResults = label.values!.filter((value) => { + // Always return selected values + if (value.selected) { + value.highlightParts = undefined; + return true; + } + const fuzzyMatchResult = fuzzyMatch(value.name.toLowerCase(), searchTerm.toLowerCase()); + if (fuzzyMatchResult.found) { + value.highlightParts = fuzzyMatchResult.ranges; + value.order = fuzzyMatchResult.distance; + return true; + } else { + return false; + } + }); + return { + ...label, + values: sortBy(searchResults, (value) => (value.selected ? -Infinity : value.order)), + }; + }); + } else { + // Clear highlight parts when searchTerm is cleared + selectedLabels = this.state.labels + .filter((label) => label.selected && label.values) + .map((label) => ({ + ...label, + values: label?.values ? label.values.map((value) => ({ ...value, highlightParts: undefined })) : [], + })); + } + return (
@@ -431,7 +470,7 @@ export class UnthemedLokiLabelBrowser extends React.Component (label.values as FacettableValue[])[i].name} width={200} className={styles.valueList} @@ -447,6 +486,7 @@ export class UnthemedLokiLabelBrowser extends React.Component diff --git a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx index d56b24e0c8a..6bbc052c310 100644 --- a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx +++ b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx @@ -1,12 +1,20 @@ import React, { ChangeEvent } from 'react'; -import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, stylesFactory, withTheme } from '@grafana/ui'; +import { + Button, + HorizontalGroup, + Input, + Label, + LoadingPlaceholder, + stylesFactory, + withTheme, + BrowserLabel as PromLabel, +} from '@grafana/ui'; import PromQlLanguageProvider from '../language_provider'; import { css, cx } from '@emotion/css'; import store from 'app/core/store'; import { FixedSizeList } from 'react-window'; import { GrafanaTheme } from '@grafana/data'; -import { Label as PromLabel } from './Label'; // Hard limit on labels to render const MAX_LABEL_COUNT = 10000; @@ -591,7 +599,7 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component (label.values as FacettableValue[])[i].name} width={200} className={styles.valueList} diff --git a/scripts/ci-reference-docs-lint.sh b/scripts/ci-reference-docs-lint.sh index 6affe8c1963..ef6e8add94e 100755 --- a/scripts/ci-reference-docs-lint.sh +++ b/scripts/ci-reference-docs-lint.sh @@ -29,7 +29,7 @@ if [ ! -d "$REPORT_PATH" ]; then fi WARNINGS_COUNT="$(find "$REPORT_PATH" -type f -name \*.log -print0 | xargs -0 grep -o "Warning: " | wc -l | xargs)" -WARNINGS_COUNT_LIMIT=1072 +WARNINGS_COUNT_LIMIT=1074 if [ "$WARNINGS_COUNT" -gt $WARNINGS_COUNT_LIMIT ]; then echo -e "API Extractor warnings/errors $WARNINGS_COUNT exceeded $WARNINGS_COUNT_LIMIT so failing build.\n"