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() {