[Lens] [Unified Search] [Dashboards] [Maps] fuzzy search for field pickers and field lists (#186894)

To @elastic/kibana-esql and @elastic/appex-sharedux reviewers - the only
change in your code is moving to a `fastest-levenshtein` npm module -
it's way more performant than `js-levenshtein` so I replaced it
everywhere in the codebase (see screenshots at the bottom of this
description).

### Summary
This PR implements fuzzy search for field pickers and field lists across
Kibana.
Fixes https://github.com/elastic/kibana/issues/179898


### Details
* We allow one typo (addition, omission, or substitution of a character)
in the search phrase. Allowing more than one typo results in too many
nonsensical suggestions.

* The feature does not activate for search phrases shorter than 4
characters to avoid nonsensical results (2 or 3 characters and one of
them is typo doesn't sounds correct). If we were to turn it on for
phrases with 3 characters, for example for `geo` search string we'll
activate "messa**ge**", "a**ge**nt" etc (see screenshot) The decision is
up to us, but to me it feels unneccessary.

<img width="532" alt="Screenshot 2024-06-26 at 11 23 17"
src="5ee04e55-fed6-4e4b-9bac-71bcc14b92d0">

* Wildcards and fuzzy search do not work together to avoid performance
problems and potential complex implementation. For example for searching
a field named `content.twitter.image`:
  * `contnt.twitter` (typo) matches the field by fuzzy search.
  * `content:*image` matches the field by wildcard.
  * `content mage` matches the field by space wildcard.
* `contnt:*image` will not work, as combining wildcards with fuzzy
search can significantly impact performance.
  
#### Peformance improvements (tested on metricbeat data and ~6000
fields)

Unfortunately, adding fuzzy search affects the performance. Before
typing a character in a list so big (6000 fields) would be around 80ms
on my Macbook on dev environment, after adding a fuzzy search it was
around 120ms so I introduced some more performance improvements:

* the fuzzy search compares strings of similar lengths to the search
phrase (±1 character in length) only (not building the whole matrix).
* I also turned off the `i` flag for regex checking wildcards and used
`toLowerCase` instead. That speeds up things a little (10% improvement
checking 50 iterations)
* I also replaced the js-levenshtein npm module with fastest-levenshtein
- that gives around 20-30% speed improvement and with other
optimizations, we cannot see the difference between 'before' and
'after'. (for comparison, see after without moving to
fastest-levenshtein)

I ran the performance profiling many times and the results were stable,
not differing a lot.

**TEST: Typing the string activemp**

before:
<img width="463" alt="Screenshot 2024-06-28 at 11 17 42"
src="42b96783-6d11-4d25-8f21-ef30d8b50167">

after:
<img width="546" alt="Screenshot 2024-06-28 at 11 42 10"
src="33c7c81b-34cc-4e01-9826-7c88d224f938">

after without moving to fastest-levenshtein:
<img width="506" alt="Screenshot 2024-06-28 at 12 55 15"
src="c5b08e7d-aa3b-4430-986f-975bfe46dec6">




### Example

<img width="887" alt="Screenshot 2024-06-25 at 16 14 10"
src="4ba0a892-c22e-4dfc-80c2-18b7b3f2e260">
This commit is contained in:
Marta Bondyra 2024-07-10 17:48:25 +02:00 committed by GitHub
parent 97e1163b49
commit ee7c047653
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 256 additions and 80 deletions

View file

@ -1035,6 +1035,7 @@
"extract-zip": "^2.0.1",
"fast-deep-equal": "^3.1.1",
"fast-glob": "^3.3.2",
"fastest-levenshtein": "^1.0.12",
"fflate": "^0.6.9",
"file-saver": "^1.3.8",
"flat": "5",
@ -1064,7 +1065,6 @@
"joi": "^17.13.3",
"joi-to-json": "^4.3.0",
"jquery": "^3.5.0",
"js-levenshtein": "^1.1.6",
"js-search": "^1.4.3",
"js-sha256": "^0.9.0",
"js-yaml": "^3.14.1",
@ -1484,7 +1484,6 @@
"@types/inquirer": "^7.3.1",
"@types/jest": "^29.5.3",
"@types/jquery": "^3.3.31",
"@types/js-levenshtein": "^1.1.0",
"@types/js-search": "^1.4.0",
"@types/js-yaml": "^3.11.1",
"@types/jsdom": "^20.0.1",

View file

@ -22,7 +22,7 @@ SRCS = glob(
SHARED_DEPS = [
"//packages/kbn-i18n",
"@npm//js-levenshtein",
"@npm//fastest-levenshtein",
]
js_library(

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import levenshtein from 'js-levenshtein';
import { distance } from 'fastest-levenshtein';
import type { AstProviderFn, ESQLAst, EditorError, ESQLMessage } from '@kbn/esql-ast';
import { uniqBy } from 'lodash';
import {
@ -90,8 +90,7 @@ function createAction(title: string, solution: string, error: EditorError): Code
async function getSpellingPossibilities(fn: () => Promise<string[]>, errorText: string) {
const allPossibilities = await fn();
const allSolutions = allPossibilities.reduce((solutions, item) => {
const distance = levenshtein(item, errorText);
if (distance < 3) {
if (distance(item, errorText) < 3) {
solutions.push(item);
}
return solutions;
@ -306,8 +305,8 @@ async function getSpellingActionForMetadata(
) {
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
const allSolutions = METADATA_FIELDS.reduce((solutions, item) => {
const distance = levenshtein(item, errorText);
if (distance < 3) {
const dist = distance(item, errorText);
if (dist < 3) {
solutions.push(item);
}
return solutions;

View file

@ -6,7 +6,6 @@
* Side Public License, v 1.
*/
import { type DataViewField } from '@kbn/data-views-plugin/common';
import {
fieldNameWildcardMatcher,
getFieldSearchMatchingHighlight,
@ -16,54 +15,107 @@ const name = 'test.this_value.maybe';
describe('fieldNameWildcardMatcher', function () {
describe('fieldNameWildcardMatcher()', () => {
it('should work correctly with wildcard', async () => {
expect(fieldNameWildcardMatcher({ displayName: 'test' } as DataViewField, 'no')).toBe(false);
expect(
fieldNameWildcardMatcher({ displayName: 'test', name: 'yes' } as DataViewField, 'yes')
).toBe(true);
expect(fieldNameWildcardMatcher({ displayName: 'test', name: 'test' }, 'no')).toBe(false);
expect(fieldNameWildcardMatcher({ displayName: 'test', name: 'yes' }, 'yes')).toBe(true);
const search = 'test*ue';
expect(fieldNameWildcardMatcher({ displayName: 'test' } as DataViewField, search)).toBe(
false
);
expect(fieldNameWildcardMatcher({ displayName: 'test.value' } as DataViewField, search)).toBe(
true
);
expect(fieldNameWildcardMatcher({ name: 'test.this_value' } as DataViewField, search)).toBe(
true
);
expect(fieldNameWildcardMatcher({ name: 'message.test' } as DataViewField, search)).toBe(
false
);
expect(fieldNameWildcardMatcher({ name } as DataViewField, search)).toBe(false);
expect(fieldNameWildcardMatcher({ name } as DataViewField, `${search}*`)).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, '*value*')).toBe(true);
expect(fieldNameWildcardMatcher({ displayName: 'test', name: 'test' }, search)).toBe(false);
expect(
fieldNameWildcardMatcher({ displayName: 'test.value', name: 'test.value' }, search)
).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'test.this_value' }, search)).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'message.test' }, search)).toBe(false);
expect(fieldNameWildcardMatcher({ name }, search)).toBe(false);
expect(fieldNameWildcardMatcher({ name }, `${search}*`)).toBe(true);
expect(fieldNameWildcardMatcher({ name }, '*value*')).toBe(true);
});
it('should work correctly with spaces', async () => {
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test maybe ')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test maybe*')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test. this')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'this _value be')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'this')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, ' value ')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'be')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test this here')).toBe(false);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test that')).toBe(false);
expect(fieldNameWildcardMatcher({ name } as DataViewField, ' ')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'geo.location3' } as DataViewField, '3')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'geo_location3' } as DataViewField, 'geo 3')).toBe(
true
);
expect(fieldNameWildcardMatcher({ name }, 'test maybe ')).toBe(true);
expect(fieldNameWildcardMatcher({ name }, 'test maybe*')).toBe(true);
expect(fieldNameWildcardMatcher({ name }, 'test. this')).toBe(true);
expect(fieldNameWildcardMatcher({ name }, 'this _value be')).toBe(true);
expect(fieldNameWildcardMatcher({ name }, 'test')).toBe(true);
expect(fieldNameWildcardMatcher({ name }, 'this')).toBe(true);
expect(fieldNameWildcardMatcher({ name }, ' value ')).toBe(true);
expect(fieldNameWildcardMatcher({ name }, 'be')).toBe(true);
expect(fieldNameWildcardMatcher({ name }, 'test this here')).toBe(false);
expect(fieldNameWildcardMatcher({ name }, 'test that')).toBe(false);
expect(fieldNameWildcardMatcher({ name }, ' ')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'geo.location3' }, '3')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'geo_location3' }, 'geo 3')).toBe(true);
});
it('should be case-insensitive', async () => {
expect(fieldNameWildcardMatcher({ name: 'Test' } as DataViewField, 'test')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'test' } as DataViewField, 'Test')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' } as DataViewField, 'Tes*')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' } as DataViewField, 'tes*')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' } as DataViewField, 't T')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' } as DataViewField, 't t')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'Test' }, 'test')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'test' }, 'Test')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' }, 'Tes*')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' }, 'tes*')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' }, 't T')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' }, 't t')).toBe(true);
});
describe('fuzzy search', () => {
test('only matches strings longer than 3 characters', () => {
expect(fieldNameWildcardMatcher({ name: 'a' }, 'b')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'ab' }, 'cb')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'abc' }, 'abb')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'abcd' }, 'abbd')).toBe(true);
});
test('is case insensitive', () => {
expect(fieldNameWildcardMatcher({ name: 'abcdefg' }, 'AAbcdef')).toBe(true);
});
test('tests both displayName and name', () => {
expect(fieldNameWildcardMatcher({ name: 'abcdefg' }, 'aabcdefg')).toBe(true);
expect(
fieldNameWildcardMatcher({ name: 'abcdefg', displayName: 'irrelevantstring' }, 'bbcdefg')
).toBe(true);
expect(
fieldNameWildcardMatcher({ name: 'irrelevantstring', displayName: 'abcdefg' }, 'bbcdefg')
).toBe(true);
});
test('finds matches with a typo at the beginning of the string', () => {
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmno' }, '.bcdefghijklmno')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmno' }, '.bcde')).toBe(true);
});
test('finds matches with a typo in the middle of the string', () => {
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmno' }, 'abcdefghi.klmno')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmno' }, 'ghi.klm')).toBe(true);
});
test('finds matches with a typo at the end of the string', () => {
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmno' }, 'abcdefghijklmn.')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmno' }, 'klmn.')).toBe(true);
});
test('finds matches with an additional character at the beginning of the string', () => {
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmn' }, '.abcdefghijklmn')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmn' }, '.abcde')).toBe(true);
});
test('finds matches with an additional character in the middle of the string', () => {
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmn' }, 'abcdefgh.ijklmn')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmn' }, 'fgh.ijklm')).toBe(true);
});
test('finds matches with an additional character at the end of the string', () => {
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmn' }, 'abcdefghijklmn.')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmn' }, 'ghijklmn.')).toBe(true);
});
test('finds matches with a missing character in the middle of the string', () => {
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmn' }, 'abcdefgijklmn')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'abcdefghijklmn' }, 'gijkl')).toBe(true);
});
test('does not find matches exceeding edit distance 1', () => {
// swapping edit distance = 2
expect(fieldNameWildcardMatcher({ name: 'abcdefhghijklm' }, 'abdcefhghijklm')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'abcdefhghijklm' }, 'abdce')).toBe(false);
// 2 char removed
expect(fieldNameWildcardMatcher({ name: 'abcdefhghijklm' }, 'abcfhghijklm')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'abcdefhghijklm' }, 'abcfhg')).toBe(false);
// 2 chars added
expect(fieldNameWildcardMatcher({ name: 'abcdefhghijklm' }, 'abcfhghijklmmm')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'abcdefhghijklm' }, 'hijklmmm')).toBe(false);
// 2 typos
expect(fieldNameWildcardMatcher({ name: 'abcdefhghijklm' }, 'cccdefhghijklm')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'abcdefhghijklm' }, 'cccdefh')).toBe(false);
});
});
});
@ -74,10 +126,10 @@ describe('fieldNameWildcardMatcher', function () {
expect(getFieldSearchMatchingHighlight('test this')).toBe('');
});
it('should correctly return a full match for a wildcard search', async () => {
expect(getFieldSearchMatchingHighlight('Test this', 'test*')).toBe('Test this');
expect(getFieldSearchMatchingHighlight('test this', '*this')).toBe('test this');
expect(getFieldSearchMatchingHighlight('test this', ' te th')).toBe('test this');
it('should correctly return a match for a wildcard search', async () => {
expect(getFieldSearchMatchingHighlight('Test this', 'test*')).toBe('test');
expect(getFieldSearchMatchingHighlight('test this', '*this')).toBe(' this');
expect(getFieldSearchMatchingHighlight('test this', ' te th')).toBe('t th');
});
});
});

View file

@ -7,6 +7,7 @@
*/
import { escapeRegExp, memoize } from 'lodash';
import { distance } from 'fastest-levenshtein';
const makeRegEx = memoize(function makeRegEx(glob: string) {
const trimmedGlob = glob.trim();
@ -21,7 +22,7 @@ const makeRegEx = memoize(function makeRegEx(glob: string) {
globRegex = '.*' + globRegex + '.*';
}
return new RegExp(globRegex.includes('*') ? `^${globRegex}$` : globRegex, 'i');
return new RegExp(globRegex.includes('*') ? `^${globRegex}$` : globRegex);
});
/**
@ -37,11 +38,61 @@ export const fieldNameWildcardMatcher = (
if (!fieldSearchHighlight?.trim()) {
return false;
}
const searchLower = fieldSearchHighlight.toLowerCase();
const displayNameLower = field.displayName?.toLowerCase();
const nameLower = field.name.toLowerCase();
const regExp = makeRegEx(fieldSearchHighlight);
return (!!field.displayName && regExp.test(field.displayName)) || regExp.test(field.name);
const regExp = makeRegEx(searchLower);
const doesWildcardMatch =
(!!displayNameLower && regExp.test(displayNameLower)) || regExp.test(nameLower);
if (doesWildcardMatch) {
return true;
}
if (searchLower.length < FUZZY_STRING_MIN_LENGTH) {
return false;
}
return testFuzzySearch({ name: nameLower, displayName: displayNameLower }, searchLower);
};
const FUZZY_STRING_MIN_LENGTH = 4;
const FUZZY_SEARCH_DISTANCE = 1;
const testFuzzySearch = (field: { name: string; displayName?: string }, searchValue: string) => {
return (
Boolean(testFuzzySearchForString(field.displayName, searchValue)) ||
(field.name !== field.displayName && Boolean(testFuzzySearchForString(field.name, searchValue)))
);
};
const testFuzzySearchForString = (label: string | undefined, searchValue: string) => {
if (!label || label.length < searchValue.length - 2) {
return false;
}
const substrLength = Math.max(
Math.min(searchValue.length, label.length),
FUZZY_STRING_MIN_LENGTH
);
// performance optimization: instead of building the whole matrix,
// only iterate through the strings of the substring length +- 1 character,
// for example for searchValue = 'test' and label = 'test_value',
// we iterate through 'test', 'est_', 'st_v' (and +- character cases too).
const iterationsCount = label.length - substrLength + 1;
for (let i = 0; i <= iterationsCount; i++) {
for (let j = substrLength - 1; j <= substrLength + 1; j++) {
if (compareLevenshtein(searchValue, label.substring(i, j + i))) {
return label.substring(i, j + i);
}
}
}
return false;
};
const compareLevenshtein = (str1: string, str2: string) =>
distance(str1, str2) <= FUZZY_SEARCH_DISTANCE;
/**
* Adapts fieldNameWildcardMatcher to combobox props.
* @param field
@ -64,13 +115,15 @@ export function getFieldSearchMatchingHighlight(
displayName: string,
fieldSearchHighlight?: string
): string {
const searchHighlight = (fieldSearchHighlight || '').trim();
if (
(searchHighlight.includes('*') || searchHighlight.includes(' ')) &&
fieldNameWildcardMatcher({ name: displayName }, searchHighlight)
) {
return displayName;
if (!fieldSearchHighlight) {
return '';
}
return searchHighlight;
const searchHighlight = (fieldSearchHighlight || '').trim();
if (displayName.toLowerCase().indexOf(searchHighlight.toLowerCase()) > -1) {
return searchHighlight;
}
return (
testFuzzySearchForString(displayName.toLowerCase(), searchHighlight.toLowerCase()) ||
displayName
);
}

View file

@ -47,6 +47,7 @@ RUNTIME_DEPS = [
"@npm//@tanstack/react-query-devtools",
"@npm//classnames",
"@npm//fflate",
"@npm//fastest-levenshtein",
"@npm//history",
"@npm//jquery",
"@npm//lodash",

View file

@ -77,6 +77,7 @@ module.exports = (_, argv) => {
'@tanstack/react-query-devtools',
'classnames',
'fflate',
'fastest-levenshtein',
'history',
'io-ts',
'jquery',

View file

@ -61,6 +61,7 @@ const externals = {
redux: '__kbnSharedDeps__.Redux',
immer: '__kbnSharedDeps__.Immer',
reselect: '__kbnSharedDeps__.Reselect',
'fastest-levenshtein': '__kbnSharedDeps__.FastestLevenshtein',
/**
* big deps which are locked to a single version

View file

@ -31,6 +31,7 @@ export const ReactRouter = require('react-router');
export const ReactRouterDom = require('react-router-dom');
export const ReactRouterDomV5Compat = require('react-router-dom-v5-compat');
export const StyledComponents = require('styled-components');
export const FastestLevenshtein = require('fastest-levenshtein');
Moment.tz.load(require('moment-timezone/data/packed/latest.json'));

View file

@ -63,7 +63,9 @@ describe('UnifiedFieldList useFieldFilters()', () => {
expect(result.current.fieldSearchHighlight).toBe('time');
expect(result.current.onFilterField).toBeDefined();
expect(result.current.onFilterField!({ displayName: 'time test' } as DataViewField)).toBe(true);
expect(
result.current.onFilterField!({ displayName: 'time test', name: '' } as DataViewField)
).toBe(true);
expect(result.current.onFilterField!(dataView.getFieldByName('@timestamp')!)).toBe(true);
expect(result.current.onFilterField!(dataView.getFieldByName('bytes')!)).toBe(false);
});
@ -86,14 +88,46 @@ describe('UnifiedFieldList useFieldFilters()', () => {
expect(result.current.fieldSearchHighlight).toBe('message*me1');
expect(result.current.onFilterField).toBeDefined();
expect(result.current.onFilterField!({ displayName: 'test' } as DataViewField)).toBe(false);
expect(result.current.onFilterField!({ displayName: 'message' } as DataViewField)).toBe(false);
expect(result.current.onFilterField!({ displayName: 'message.name1' } as DataViewField)).toBe(
true
expect(result.current.onFilterField!({ displayName: 'test', name: '' } as DataViewField)).toBe(
false
);
expect(
result.current.onFilterField!({ displayName: 'message', name: '' } as DataViewField)
).toBe(false);
expect(
result.current.onFilterField!({ displayName: 'message.name1', name: '' } as DataViewField)
).toBe(true);
expect(result.current.onFilterField!({ name: 'messagename10' } as DataViewField)).toBe(false);
expect(result.current.onFilterField!({ name: 'message.test' } as DataViewField)).toBe(false);
});
it('should update correctly on search by name with a typo', async () => {
const props: FieldFiltersParams<DataViewField> = {
allFields: dataView.fields,
services: mockedServices,
};
const { result } = renderHook(useFieldFilters, {
initialProps: props,
});
expect(result.current.fieldSearchHighlight).toBe('');
expect(result.current.onFilterField).toBeUndefined();
act(() => {
result.current.fieldListFiltersProps.onChangeNameFilter('messsge');
});
expect(result.current.fieldSearchHighlight).toBe('messsge');
expect(result.current.onFilterField).toBeDefined();
expect(result.current.onFilterField!({ displayName: 'test', name: '' } as DataViewField)).toBe(
false
);
expect(
result.current.onFilterField!({ displayName: 'message', name: '' } as DataViewField)
).toBe(true);
expect(
result.current.onFilterField!({ displayName: 'message.name1', name: '' } as DataViewField)
).toBe(true);
});
it('should update correctly on filter by type', async () => {
const props: FieldFiltersParams<DataViewField> = {

View file

@ -61,7 +61,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === 4;
});
await PageObjects.discover.findFieldByNameInDocViewer('.s');
await PageObjects.discover.findFieldByNameInDocViewer('.sr');
await retry.waitFor('second updates', async () => {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === 2;
@ -70,7 +70,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should be able to search by wildcard', async function () {
await PageObjects.discover.findFieldByNameInDocViewer('relatedContent*image');
await retry.waitFor('updates', async () => {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === 2;
});
@ -78,12 +77,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should be able to search with spaces as wildcard', async function () {
await PageObjects.discover.findFieldByNameInDocViewer('relatedContent image');
await retry.waitFor('updates', async () => {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === 4;
});
});
it('should be able to search with fuzzy search (1 typo)', async function () {
await PageObjects.discover.findFieldByNameInDocViewer('rel4tedContent.art');
await retry.waitFor('updates', async () => {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === 3;
});
});
it('should ignore empty search', async function () {
await PageObjects.discover.findFieldByNameInDocViewer(' '); // only spaces

View file

@ -223,6 +223,23 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
});
it('should be able to search with fuzzy search (1 typo)', async function () {
await PageObjects.unifiedFieldList.findFieldByName('rel4tedContent.art');
await retry.waitFor('updates', async () => {
return (
(await PageObjects.unifiedFieldList.getSidebarAriaDescription()) ===
'4 available fields. 0 meta fields.'
);
});
expect(
(await PageObjects.unifiedFieldList.getSidebarSectionFieldNames('available')).join(', ')
).to.be(
'relatedContent.article:modified_time, relatedContent.article:published_time, relatedContent.article:section, relatedContent.article:tag'
);
});
it('should ignore empty search', async function () {
await PageObjects.unifiedFieldList.findFieldByName(' '); // only spaces

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import levenshtein from 'js-levenshtein';
import { PublicAppInfo, PublicAppDeepLinkInfo, AppCategory } from '@kbn/core/public';
import { distance } from 'fastest-levenshtein';
import { GlobalSearchProviderResult } from '@kbn/global-search-plugin/public';
/** Type used internally to represent an application unrolled into its separate deepLinks */
@ -80,10 +80,10 @@ const scoreAppByTerms = (term: string, title: string): number => {
return 75;
}
const length = Math.max(term.length, title.length);
const distance = levenshtein(term, title);
const dist = distance(term, title);
// maximum lev distance is length, we compute the match ratio (lower distance is better)
const ratio = Math.floor((1 - distance / length) * 100);
const ratio = Math.floor((1 - dist / length) * 100);
if (ratio >= 60) {
return ratio;
}

View file

@ -176,6 +176,23 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
});
it('should be able to search with fuzzy search (1 typo)', async function () {
await PageObjects.unifiedFieldList.findFieldByName('rel4tedContent.art');
await retry.waitFor('updates', async () => {
return (
(await PageObjects.unifiedFieldList.getSidebarAriaDescription()) ===
'4 available fields. 0 meta fields.'
);
});
expect(
(await PageObjects.unifiedFieldList.getSidebarSectionFieldNames('available')).join(', ')
).to.be(
'relatedContent.article:modified_time, relatedContent.article:published_time, relatedContent.article:section, relatedContent.article:tag'
);
});
it('should ignore empty search', async function () {
await PageObjects.unifiedFieldList.findFieldByName(' '); // only spaces

View file

@ -10387,11 +10387,6 @@
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f"
integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw==
"@types/js-levenshtein@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.0.tgz#9541eec4ad6e3ec5633270a3a2b55d981edc44a9"
integrity sha512-14t0v1ICYRtRVcHASzes0v/O+TIeASb8aD55cWF1PidtInhFWSXcmhzhHqGjUWf9SUq1w70cvd1cWKUULubAfQ==
"@types/js-search@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@types/js-search/-/js-search-1.4.0.tgz#f2d4afa176a4fc7b17fb46a1593847887fa1fb7b"