From 66a39c1585570a7ca7677531d5f2351bdf252554 Mon Sep 17 00:00:00 2001 From: Jake Harding Date: Wed, 13 Mar 2013 22:33:18 -0700 Subject: [PATCH] Make value key configurable and remove ranking code. Closes #61. --- src/dataset.js | 67 +++++------------ src/dropdown_view.js | 14 ++-- src/typeahead_view.js | 21 ++---- test/dataset_spec.js | 145 ++++++++++++++++--------------------- test/dropdown_view_spec.js | 36 +++++---- test/playground.html | 1 + 6 files changed, 116 insertions(+), 168 deletions(-) diff --git a/src/dataset.js b/src/dataset.js index eda0b9c0..099174f0 100644 --- a/src/dataset.js +++ b/src/dataset.js @@ -21,7 +21,8 @@ var Dataset = (function() { this.limit = o.limit || 5; this.header = o.header; this.footer = o.footer; - this.template = compileTemplate(o.template, o.engine); + this.valueKey = o.valueKey || 'value'; + this.template = compileTemplate(o.template, o.engine, this.valueKey); // used in #initialize this.local = o.local; @@ -95,18 +96,25 @@ var Dataset = (function() { }, _processData: function(data) { - var itemHash = {}, adjacencyList = {}; + var that = this, itemHash = {}, adjacencyList = {}; - utils.each(data, function(i, item) { - var id; + utils.each(data, function(i, datum) { + var value = utils.isString(datum) ? datum : datum[that.valueKey], + tokens = datum.tokens || utils.tokenizeText(value), + item = { value: value, tokens: tokens }, + id; - // convert string datums to datum objects - if (utils.isString(item)) { - item = { value: item, tokens: utils.tokenizeText(item) }; + if (utils.isString(datum)) { + item.datum = {}; + item.datum[that.valueKey] = datum; + } + + else { + item.datum = datum; } // filter out falsy tokens - item.tokens = utils.filter(item.tokens || [], function(token) { + item.tokens = utils.filter(item.tokens, function(token) { return !utils.isBlankString(token); }); @@ -197,41 +205,6 @@ var Dataset = (function() { return suggestions; }, - _compareItems: function(a, b, areLocalItems) { - var aScoreBoost = !a.score_boost ? 0 : a.score_boost, - bScoreBoost = !b.score_boost ? 0 : b.score_boost, - aScore = !a.score ? 0 : a.score, - bScore = !b.score ? 0 : b.score; - - if(areLocalItems) { - return (b.weight + bScoreBoost) - (a.weight + aScoreBoost); - } else { - return (bScore + bScoreBoost) - (aScore + aScoreBoost); - } - }, - - _ranker: function(a, b) { - if (this._customRanker) { - return this._customRanker(a, b); - } else { - // Anything local should always be first (anything with a non-zero weight) and remote results (non-zero scores), and sort by weight/score within each category - var aIsLocal = a.weight && a.weight !== 0; - var bIsLocal = b.weight && b.weight !== 0; - if (aIsLocal && !bIsLocal) { - return -1; - } else if (bIsLocal && !aIsLocal) { - return 1; - } else { - return (aIsLocal && bIsLocal) ? this._compareItems(a, b, true) : this._compareItems(a, b, false); - } - } - }, - - _processRemoteSuggestions: function(callback, matchedItems) { - var that = this; - - }, - // public methods // --------------- @@ -256,9 +229,7 @@ var Dataset = (function() { getSuggestions: function(query, cb) { var that = this, terms = utils.tokenizeQuery(query), - suggestions = this._getLocalSuggestions(terms) - .sort(this._ranker) - .slice(0, this.limit); + suggestions = this._getLocalSuggestions(terms).slice(0, this.limit); cb && cb(suggestions); @@ -301,7 +272,7 @@ var Dataset = (function() { return Dataset; - function compileTemplate(template, engine) { + function compileTemplate(template, engine, valueKey) { var wrapper = '
%body
', compiledTemplate; @@ -314,7 +285,7 @@ var Dataset = (function() { else { compiledTemplate = { render: function(context) { - return wrapper.replace('%body', '

' + context.value + '

'); + return wrapper.replace('%body', '

' + context[valueKey] + '

'); } }; } diff --git a/src/dropdown_view.js b/src/dropdown_view.js index b0c7d227..cec5898b 100644 --- a/src/dropdown_view.js +++ b/src/dropdown_view.js @@ -52,7 +52,7 @@ var DropdownView = (function() { _handleSelection: function($e) { var $suggestion = $($e.currentTarget); - this.trigger('suggestionSelected', getSuggestionData($suggestion)); + this.trigger('suggestionSelected', extractSuggestion($suggestion)); }, _show: function() { @@ -94,7 +94,7 @@ var DropdownView = (function() { } $underCursor = $suggestions.eq(nextIndex).addClass('tt-is-under-cursor'); - this.trigger('cursorMoved', getSuggestionData($underCursor)); + this.trigger('cursorMoved', extractSuggestion($underCursor)); }, _getSuggestions: function() { @@ -166,16 +166,16 @@ var DropdownView = (function() { .filter('.tt-is-under-cursor') .first(); - return $suggestion.length > 0 ? getSuggestionData($suggestion) : null; + return $suggestion.length > 0 ? extractSuggestion($suggestion) : null; }, getFirstSuggestion: function() { var $suggestion = this._getSuggestions().first(); - return $suggestion.length > 0 ? getSuggestionData($suggestion) : null; + return $suggestion.length > 0 ? extractSuggestion($suggestion) : null; }, - renderSuggestions: function(query, dataset, suggestions) { + renderSuggestions: function(dataset, suggestions) { var datasetClassName = 'tt-dataset-' + dataset.name, $suggestionsList, $dataset = this.$menu.find('.' + datasetClassName), @@ -204,7 +204,7 @@ var DropdownView = (function() { fragment = document.createDocumentFragment(); utils.each(suggestions, function(i, suggestion) { - elBuilder.innerHTML = dataset.template.render(suggestion); + elBuilder.innerHTML = dataset.template.render(suggestion.datum); $el = $(elBuilder.firstChild) .css(css.suggestion) @@ -251,7 +251,7 @@ var DropdownView = (function() { // helper functions // ---------------- - function getSuggestionData($el) { + function extractSuggestion($el) { return $el.data('suggestion'); } })(); diff --git a/src/typeahead_view.js b/src/typeahead_view.js index 2170fccf..aa564624 100644 --- a/src/typeahead_view.js +++ b/src/typeahead_view.js @@ -217,32 +217,25 @@ var TypeaheadView = (function() { byClick && utils.isMsie() ? setTimeout(this.dropdownView.close, 0) : this.dropdownView.close(); - this.eventBus.trigger('selected', suggestion); + this.eventBus.trigger('selected', suggestion.datum); } }, _getSuggestions: function() { - var that = this, - query = this.inputView.getQuery(); + var that = this, query = this.inputView.getQuery(); - if (utils.isBlankString(query)) { - return; - } + if (utils.isBlankString(query)) { return; } utils.each(this.datasets, function(i, dataset) { dataset.getSuggestions(query, function(suggestions) { - that._renderSuggestions(query, dataset, suggestions); + // only render the suggestions if the query hasn't changed + if (query === that.inputView.getQuery()) { + that.dropdownView.renderSuggestions(dataset, suggestions); + } }); }); }, - _renderSuggestions: function(query, dataset, suggestions) { - if (query !== this.inputView.getQuery()) { return; } - - suggestions = suggestions.slice(0, dataset.limit); - this.dropdownView.renderSuggestions(query, dataset, suggestions); - }, - _autocomplete: function(e) { var isCursorAtEnd, ignoreEvent, query, hint, suggestion; diff --git a/test/dataset_spec.js b/test/dataset_spec.js index 02a6a405..0fb61743 100644 --- a/test/dataset_spec.js +++ b/test/dataset_spec.js @@ -1,20 +1,27 @@ describe('Dataset', function() { - var fixtureData = ['grape', 'coconut', 'cake', 'tea', 'coffee'], + var fixtureStrings = ['grape', 'coconut', 'cake', 'tea', 'coffee'], + fixtureDatums = [ + { value: 'grape' }, + { value: 'coconut' }, + { value: 'cake' }, + { value: 'tea' }, + { value: 'coffee' } + ], expectedAdjacencyList = { g: ['grape'], c: ['coconut', 'cake', 'coffee'], t: ['tea'] }, expectedItemHash = { - grape: { tokens: ['grape'], value: 'grape' }, - coconut: { tokens: ['coconut'], value: 'coconut' }, - cake: { tokens: ['cake'], value: 'cake' }, - tea: { tokens: ['tea'], value: 'tea' }, - coffee: { tokens: ['coffee'], value: 'coffee' } + grape: createItem('grape'), + coconut: createItem('coconut'), + cake: createItem('cake'), + tea: createItem('tea'), + coffee: createItem('coffee') }, prefetchResp = { status: 200, - responseText: JSON.stringify(fixtureData) + responseText: JSON.stringify(fixtureStrings) }, mockStorageFns = { getMiss: function() { @@ -56,7 +63,7 @@ describe('Dataset', function() { describe('#constructor', function() { it('should initialize persistent storage', function() { - expect(new Dataset({ local: fixtureData }).storage).toBeDefined(); + expect(new Dataset({ local: fixtureStrings }).storage).toBeDefined(); expect(PersistentStorage).toHaveBeenCalled(); }); @@ -82,7 +89,7 @@ describe('Dataset', function() { describe('when called with no template', function() { beforeEach(function() { - this.dataset = new Dataset({ local: fixtureData }); + this.dataset = new Dataset({ local: fixtureStrings }); }); it('should compile default template', function() { @@ -94,7 +101,7 @@ describe('Dataset', function() { describe('when called with a template and engine', function() { beforeEach(function() { this.dataset = new Dataset({ - local: fixtureData, + local: fixtureStrings, template: 't', engine: { compile: this.spy = jasmine.createSpy().andReturn('boo') } }); @@ -113,7 +120,7 @@ describe('Dataset', function() { it('should return Deferred instance', function() { var returnVal; - this.dataset = new Dataset({ local: fixtureData }); + this.dataset = new Dataset({ local: fixtureStrings }); returnVal = this.dataset.initialize(); // eh, have to rely on duck typing unfortunately @@ -124,13 +131,18 @@ describe('Dataset', function() { describe('when called with local', function() { beforeEach(function() { - this.dataset = new Dataset({ local: fixtureData }); - this.dataset.initialize(); + this.dataset1 = new Dataset({ local: fixtureStrings }); + this.dataset2 = new Dataset({ local: fixtureDatums }); + + this.dataset1.initialize(); + this.dataset2.initialize(); }); it('should process and merge the data', function() { - expect(this.dataset.itemHash).toEqual(expectedItemHash); - expect(this.dataset.adjacencyList).toEqual(expectedAdjacencyList); + expect(this.dataset1.itemHash).toEqual(expectedItemHash); + expect(this.dataset1.adjacencyList).toEqual(expectedAdjacencyList); + expect(this.dataset2.itemHash).toEqual(expectedItemHash); + expect(this.dataset2.adjacencyList).toEqual(expectedAdjacencyList); }); }); @@ -158,9 +170,7 @@ describe('Dataset', function() { describe('if filter was passed in', function() { var filteredAdjacencyList = { f: ['filter'] }, - filteredItemHash = { - filter: { tokens: ['filter'], value: 'filter' } - }; + filteredItemHash = { filter: createItem('filter') }; beforeEach(function() { this.dataset = new Dataset({ @@ -181,7 +191,7 @@ describe('Dataset', function() { expect(this.request).not.toBeNull(); }); - it('should process and merge filtered data', function() { + it('should process and merge fileered data', function() { expect(this.dataset.adjacencyList).toEqual(filteredAdjacencyList); expect(this.dataset.itemHash).toEqual(filteredItemHash); }); @@ -251,31 +261,9 @@ describe('Dataset', function() { }); }); - describe('Datasource options', function() { + describe('Matching, combining, returning results', function() { beforeEach(function() { - this.dataset = new Dataset({ local: fixtureData }); - this.dataset.initialize(); - }); - - it('allow for a custom ranking function to be defined', function() { - this.dataset._customRanker = function(a, b) { - return a.value.length > b.value.length ? - 1 : a.value.length === b.value.length ? 0 : -1; - }; - - this.dataset.getSuggestions('c', function(items) { - expect(items).toEqual([ - { tokens: ['cake'], value: 'cake' }, - { tokens: ['coffee'], value: 'coffee' }, - { tokens: ['coconut'], value: 'coconut' } - ]); - }); - }); - }); - - describe('Matching, ranking, combining, returning results', function() { - beforeEach(function() { - this.dataset = new Dataset({ local: fixtureData, remote: '/remote' }); + this.dataset = new Dataset({ local: fixtureStrings, remote: '/remote' }); this.dataset.initialize(); }); @@ -283,9 +271,9 @@ describe('Dataset', function() { this.dataset.limit = 3; this.dataset.getSuggestions('c', function(items) { expect(items).toEqual([ - { tokens: ['coconut'], value: 'coconut' }, - { tokens: ['cake'], value: 'cake' }, - { tokens: ['coffee'], value: 'coffee' } + createItem('coconut'), + createItem('cake'), + createItem('coffee') ]); }); @@ -294,9 +282,9 @@ describe('Dataset', function() { this.dataset.limit = 100; this.dataset.getSuggestions('c', function(items) { expect(items).toEqual([ - { tokens: ['coconut'], value: 'coconut' }, - { tokens: ['cake'], value: 'cake' }, - { tokens: ['coffee'], value: 'coffee' } + createItem('coconut'), + createItem('cake'), + createItem('coffee') ]); }); @@ -306,9 +294,9 @@ describe('Dataset', function() { it('matches', function() { this.dataset.getSuggestions('c', function(items) { expect(items).toEqual([ - { tokens: ['coconut'], value: 'coconut' }, - { tokens: ['cake'], value: 'cake' }, - { tokens: ['coffee'], value: 'coffee' } + createItem('coconut'), + createItem('cake'), + createItem('coffee') ]); }); }); @@ -350,22 +338,6 @@ describe('Dataset', function() { expectedItemHash.grape ]); }); - - it('sorts results: local first, then remote, sorted by graph weight / score within each local/remote section', function() { - expect([ - { id: 1, weight: 1000, score: 0 }, - { id: 2, weight: 500, score: 0 }, - { id: 3, weight: 1500, score: 0 }, - { id: 4, weight: 0, score: 100000 }, - { id: 5, weight: 0, score: 250000 } - ].sort(this.dataset._ranker)).toEqual([ - { id: 3, weight: 1500, score: 0 }, - { id: 1, weight: 1000, score: 0 }, - { id: 2, weight: 500, score: 0 }, - { id: 5, weight: 0, score: 250000 }, - { id: 4, weight: 0, score: 100000 } - ]); - }); }); describe('tokenization', function() { @@ -379,27 +351,22 @@ describe('Dataset', function() { it('normalizes capitalization to match items', function() { this.dataset.getSuggestions('Cours', function(items) { - expect(items) - .toEqual([{ tokens: ['course', '106'], value: 'course-106' }]); + expect(items).toEqual([createItem('course-106')]); }); this.dataset.getSuggestions('cOuRsE 106', function(items) { - expect(items) - .toEqual([{ tokens: ['course', '106'], value: 'course-106' }]); + expect(items).toEqual([createItem('course-106')]); }); this.dataset.getSuggestions('one two', function(items) { - expect(items) - .toEqual([{ tokens: ['one', 'two'], value: 'One-Two' }]); + expect(items).toEqual([createItem('One-Two')]); }); this.dataset.getSuggestions('THREE TWO', function(items) { - expect(items) - .toEqual([{ tokens: ['two', 'three'], value: 'two three' }]); + expect(items).toEqual([createItem('two three')]); }); }); it('matches items with dashes', function() { this.dataset.getSuggestions('106 course', function(items) { - expect(items) - .toEqual([{ tokens: ['course', '106'], value: 'course-106' }]); + expect(items).toEqual([createItem('course-106')]); }); this.dataset.getSuggestions('course-106', function(items) { expect(items).toEqual([]); @@ -408,15 +375,14 @@ describe('Dataset', function() { it('matches items with underscores', function() { this.dataset.getSuggestions('user name', function(items) { - expect(items) - .toEqual([{ tokens: ['user', 'name'], value: 'user_name' }]); + expect(items).toEqual([createItem('user_name')]); }); }); }); }); describe('with datum objects', function() { - var fixtureData = [{ value: 'course-106', tokens: ['course-106'] }]; + var fixtureData = [{ value: 'course-106' }]; beforeEach(function() { this.dataset = new Dataset({ local: fixtureData }); @@ -425,13 +391,24 @@ describe('Dataset', function() { it('matches items with dashes', function() { this.dataset.getSuggestions('106 course', function(items) { - expect(items).toEqual([]); + expect(items).toEqual([createItem('course-106')]); }); this.dataset.getSuggestions('course-106', function(items) { - expect(items) - .toEqual([{ value: 'course-106', tokens: ['course-106'] }]); + expect(items).toEqual([]); }); }); }); + + // helper functions + // ---------------- + + function createItem(val) { + return { + value: val, + tokens: utils.tokenizeText(val), + datum: { value: val } + }; + } }); + diff --git a/test/dropdown_view_spec.js b/test/dropdown_view_spec.js index 3aeb0728..dd0cdad8 100644 --- a/test/dropdown_view_spec.js +++ b/test/dropdown_view_spec.js @@ -78,7 +78,7 @@ describe('DropdownView', function() { it('should trigger suggestionSelected', function() { expect(this.spy).toHaveBeenCalledWith({ type: 'suggestionSelected', - data: { value: 'one' } + data: { value: 'one', tokens: ['one'], datum: { value: 'one' } } }); }); }); @@ -431,7 +431,11 @@ describe('DropdownView', function() { suggestion = this.dropdownView.getSuggestionUnderCursor(); - expect(suggestion).toEqual({ value: 'one' }); + expect(suggestion).toEqual({ + value: 'one', + tokens: ['one'], + datum: { value: 'one' } + }); }); }); }); @@ -444,7 +448,11 @@ describe('DropdownView', function() { it('should return obj with data about suggestion under the cursor', function() { var suggestion = this.dropdownView.getFirstSuggestion(); - expect(suggestion).toEqual({ value: 'one' }); + expect(suggestion).toEqual({ + value: 'one', + tokens: ['one'], + datum: { value: 'one' } + }); }); }); @@ -471,7 +479,7 @@ describe('DropdownView', function() { describe('if new dataset', function() { beforeEach(function() { - this.dropdownView.renderSuggestions('query', mockNewDataset, []); + this.dropdownView.renderSuggestions(mockNewDataset, []); }); it('should render the header', function() { @@ -498,7 +506,7 @@ describe('DropdownView', function() { spyOn(this.dropdownView, 'clearSuggestions'); - this.dropdownView.renderSuggestions('query', mockOldDataset, []); + this.dropdownView.renderSuggestions(mockOldDataset, []); }); it('should call clearSuggestions', function() { @@ -518,20 +526,19 @@ describe('DropdownView', function() { spyOn(this.dropdownView, 'clearSuggestions').andCallThrough(); this.dropdownView.renderSuggestions( - 'query', mockOldDataset, - [{ value: 'i am a value' }] + [{ datum: { value: 'i am a value' } }] ); }); it('should overwrite previous suggestions', function() { var $suggestions = this.$testDataset.children(), $suggestion = $suggestions.first(), - suggestion = $suggestion.data('suggestion'); + datum = $suggestion.data('suggestion').datum; expect($suggestions.length).toBe(1); expect($suggestion).toHaveText('i am a value'); - expect(suggestion).toEqual({ value: 'i am a value' }); + expect(datum).toEqual({ value: 'i am a value' }); }); it('should trigger suggestionsRendered', function() { @@ -563,8 +570,7 @@ describe('DropdownView', function() { // ---------------- function renderTestDataset(view, open) { - var mockQuery = 'test q', - mockDataset = { + var mockDataset = { name: 'test' , template: { render: function(c) { @@ -573,12 +579,12 @@ describe('DropdownView', function() { } }, mockSuggestions = [ - { value: 'one' }, - { value: 'two' }, - { value: 'three' } + { value: 'one', tokens: ['one'], datum: { value: 'one' } }, + { value: 'two', tokens: ['two'], datum: { value: 'two' } }, + { value: 'three', tokens: ['three'], datum: { value: 'three' } } ]; - view.renderSuggestions(mockQuery, mockDataset, mockSuggestions); + view.renderSuggestions(mockDataset, mockSuggestions); open && view.open(); return $('#jasmine-fixtures .tt-dataset-test > .tt-suggestions'); diff --git a/test/playground.html b/test/playground.html index 108f9903..39f1593e 100644 --- a/test/playground.html +++ b/test/playground.html @@ -58,6 +58,7 @@ ].join(' '), logToTextarea); $('.states').typeahead({ + valueKey: 'state', local: [ "Alabama", "Alaska",