From 6af4d82ce7894c6dc033141dbd73cf78901ff4de Mon Sep 17 00:00:00 2001 From: Mike Pennisi Date: Wed, 17 Jun 2020 23:31:02 -0400 Subject: [PATCH 1/3] Extend QueryMatcher's sorting heuristic Use the order of the input keys as a signal for relative importance of matches. Signed-off-by: Mike Pennisi --- src/autocomplete/QueryMatcher.ts | 32 +++++++++++++---------- test/autocomplete/QueryMatcher-test.js | 35 +++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index 2c1899d813..4b8c1141fd 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -18,7 +18,6 @@ limitations under the License. import _at from 'lodash/at'; import _flatMap from 'lodash/flatMap'; -import _sortBy from 'lodash/sortBy'; import _uniq from 'lodash/uniq'; function stripDiacritics(str: string): string { @@ -35,8 +34,9 @@ interface IOptions { /** * Simple search matcher that matches any results with the query string anywhere * in the search string. Returns matches in the order the query string appears - * in the search key, earliest first, then in the order the items appeared in - * the source array. + * in the search key, earliest first, then in the order the search key appears + * in the provided array of keys, then in the order the items appeared in the + * source array. * * @param {Object[]} objects Initial list of objects. Equivalent to calling * setObjects() after construction @@ -49,7 +49,7 @@ export default class QueryMatcher { private _options: IOptions; private _keys: IOptions["keys"]; private _funcs: Required["funcs"]>; - private _items: Map; + private _items: Map<{value: string, weight: number}, T[]>; constructor(objects: T[], options: IOptions = { keys: [] }) { this._options = options; @@ -85,9 +85,12 @@ export default class QueryMatcher { keyValues.push(f(object)); } - for (const keyValue of keyValues) { + for (const [index, keyValue] of Object.entries(keyValues)) { if (!keyValue) continue; // skip falsy keyValues - const key = stripDiacritics(keyValue).toLowerCase(); + const key = { + value: stripDiacritics(keyValue).toLowerCase(), + weight: Number(index) + }; if (!this._items.has(key)) { this._items.set(key, []); } @@ -109,7 +112,7 @@ export default class QueryMatcher { // ES6 Map iteration order is defined to be insertion order, so results // here will come out in the order they were put in. for (const key of this._items.keys()) { - let resultKey = key; + let {value: resultKey} = key; if (this._options.shouldMatchWordsOnly) { resultKey = resultKey.replace(/[^\w]/g, ''); } @@ -119,12 +122,15 @@ export default class QueryMatcher { } } - // Sort them by where the query appeared in the search key - // lodash sortBy is a stable sort, so results where the query - // appeared in the same place will retain their order with - // respect to each other. - const sortedResults = _sortBy(results, (candidate) => { - return candidate.index; + // Sort them by where the query appeared in the search key, then by + // where the matched key appeared in the provided array of keys. + const sortedResults = results.slice().sort((a, b) => { + if (a.index < b.index) { + return -1; + } else if (a.index === b.index && a.key.weight < b.key.weight) { + return -1; + } + return 1; }); // Now map the keys to the result objects. Each result object is a list, so diff --git a/test/autocomplete/QueryMatcher-test.js b/test/autocomplete/QueryMatcher-test.js index 03f28eb984..2d0e10563b 100644 --- a/test/autocomplete/QueryMatcher-test.js +++ b/test/autocomplete/QueryMatcher-test.js @@ -81,7 +81,34 @@ describe('QueryMatcher', function() { expect(reverseResults[1].name).toBe('Victoria'); }); - it('Returns results with search string in same place in insertion order', function() { + it('Returns results with search string in same place according to key index', function() { + const objects = [ + { name: "a", first: "hit", second: "miss", third: "miss" }, + { name: "b", first: "miss", second: "hit", third: "miss" }, + { name: "c", first: "miss", second: "miss", third: "hit" }, + ]; + const qm = new QueryMatcher(objects, {keys: ["second", "first", "third"]}); + const results = qm.match('hit'); + + expect(results.length).toBe(3); + expect(results[0].name).toBe('b'); + expect(results[1].name).toBe('a'); + expect(results[2].name).toBe('c'); + + + qm.setObjects(objects.slice().reverse()); + + const reverseResults = qm.match('hit'); + + // should still be in the same order: key index + // takes precedence over input order + expect(reverseResults.length).toBe(3); + expect(reverseResults[0].name).toBe('b'); + expect(reverseResults[1].name).toBe('a'); + expect(reverseResults[2].name).toBe('c'); + }); + + it('Returns results with search string in same place and key in same place in insertion order', function() { const qm = new QueryMatcher(OBJECTS, {keys: ["name"]}); const results = qm.match('Mel'); @@ -132,9 +159,9 @@ describe('QueryMatcher', function() { const results = qm.match('Emma'); expect(results.length).toBe(3); - expect(results[0].name).toBe('Mel B'); - expect(results[1].name).toBe('Mel C'); - expect(results[2].name).toBe('Emma'); + expect(results[0].name).toBe('Emma'); + expect(results[1].name).toBe('Mel B'); + expect(results[2].name).toBe('Mel C'); }); it('Matches words only by default', function() { From 4ffc54d143ae173c872d7aa2c40ea50321db22d1 Mon Sep 17 00:00:00 2001 From: Mike Pennisi Date: Thu, 18 Jun 2020 13:24:02 -0400 Subject: [PATCH 2/3] fixup! Extend QueryMatcher's sorting heuristic --- src/autocomplete/QueryMatcher.ts | 42 +++++++++++++++++--------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index 4b8c1141fd..a11928c1dd 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -17,7 +17,6 @@ limitations under the License. */ import _at from 'lodash/at'; -import _flatMap from 'lodash/flatMap'; import _uniq from 'lodash/uniq'; function stripDiacritics(str: string): string { @@ -49,7 +48,7 @@ export default class QueryMatcher { private _options: IOptions; private _keys: IOptions["keys"]; private _funcs: Required["funcs"]>; - private _items: Map<{value: string, weight: number}, T[]>; + private _items: Map; constructor(objects: T[], options: IOptions = { keys: [] }) { this._options = options; @@ -87,14 +86,14 @@ export default class QueryMatcher { for (const [index, keyValue] of Object.entries(keyValues)) { if (!keyValue) continue; // skip falsy keyValues - const key = { - value: stripDiacritics(keyValue).toLowerCase(), - weight: Number(index) - }; + const key = stripDiacritics(keyValue).toLowerCase(); if (!this._items.has(key)) { this._items.set(key, []); } - this._items.get(key).push(object); + this._items.get(key).push({ + keyWeight: Number(index), + object, + }); } } } @@ -107,35 +106,40 @@ export default class QueryMatcher { if (query.length === 0) { return []; } - const results = []; + const matches = []; // Iterate through the map & check each key. // ES6 Map iteration order is defined to be insertion order, so results // here will come out in the order they were put in. - for (const key of this._items.keys()) { - let {value: resultKey} = key; + for (const [key, candidates] of this._items.entries()) { + let resultKey = key; if (this._options.shouldMatchWordsOnly) { resultKey = resultKey.replace(/[^\w]/g, ''); } const index = resultKey.indexOf(query); if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) { - results.push({key, index}); + matches.push( + ...candidates.map((candidate) => ({key, index, ...candidate})) + ); } } - // Sort them by where the query appeared in the search key, then by + // Sort matches by where the query appeared in the search key, then by // where the matched key appeared in the provided array of keys. - const sortedResults = results.slice().sort((a, b) => { + matches.sort((a, b) => { if (a.index < b.index) { return -1; - } else if (a.index === b.index && a.key.weight < b.key.weight) { - return -1; + } else if (a.index === b.index) { + if (a.keyWeight < b.keyWeight) { + return -1; + } else if (a.keyWeight === b.keyWeight) { + return 0; + } } + return 1; }); - // Now map the keys to the result objects. Each result object is a list, so - // flatMap will flatten those lists out into a single list. Also remove any - // duplicates. - return _uniq(_flatMap(sortedResults, (candidate) => this._items.get(candidate.key))); + // Now map the keys to the result objects. Also remove any duplicates. + return _uniq(matches.map((match) => match.object)); } } From 2e0cb4746a818cc328b1d205bb1956c7f354ceb3 Mon Sep 17 00:00:00 2001 From: Mike Pennisi Date: Thu, 18 Jun 2020 14:20:40 -0400 Subject: [PATCH 3/3] fixup! Extend QueryMatcher's sorting heuristic --- src/autocomplete/QueryMatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index a11928c1dd..7a0219e264 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -118,7 +118,7 @@ export default class QueryMatcher { const index = resultKey.indexOf(query); if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) { matches.push( - ...candidates.map((candidate) => ({key, index, ...candidate})) + ...candidates.map((candidate) => ({index, ...candidate})) ); } }