mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
97e1163b49
commit
ee7c047653
15 changed files with 256 additions and 80 deletions
|
@ -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",
|
||||
|
|
|
@ -22,7 +22,7 @@ SRCS = glob(
|
|||
|
||||
SHARED_DEPS = [
|
||||
"//packages/kbn-i18n",
|
||||
"@npm//js-levenshtein",
|
||||
"@npm//fastest-levenshtein",
|
||||
]
|
||||
|
||||
js_library(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -47,6 +47,7 @@ RUNTIME_DEPS = [
|
|||
"@npm//@tanstack/react-query-devtools",
|
||||
"@npm//classnames",
|
||||
"@npm//fflate",
|
||||
"@npm//fastest-levenshtein",
|
||||
"@npm//history",
|
||||
"@npm//jquery",
|
||||
"@npm//lodash",
|
||||
|
|
|
@ -77,6 +77,7 @@ module.exports = (_, argv) => {
|
|||
'@tanstack/react-query-devtools',
|
||||
'classnames',
|
||||
'fflate',
|
||||
'fastest-levenshtein',
|
||||
'history',
|
||||
'io-ts',
|
||||
'jquery',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'));
|
||||
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue