Skip to content

Commit

Permalink
[#2695] Refactor and tested the autocomplete module
Browse files Browse the repository at this point in the history
  • Loading branch information
aron committed Jul 23, 2012
1 parent aa9a2b4 commit 85d9b38
Show file tree
Hide file tree
Showing 2 changed files with 277 additions and 20 deletions.
125 changes: 105 additions & 20 deletions ckan/public/base/javascript/modules/autocomplete.js
@@ -1,9 +1,22 @@
/* An auto-complete module for select and input elements that can pull in
* a list of terms from an API endpoint (provided using data-module-source).
*
* source - A url pointing to an API autocomplete endpoint.
* interval - The interval between requests in milliseconds (default: 1000).
* items - The max number of items to display (default: 10)
*
* Examples
*
* // <input name="tags" data-module="autocomplete" data-module-source="http://" />
*
*/
this.ckan.module('autocomplete', function (jQuery, _) {
return {
source: null,
/* Options for the module */
options: {
items: 10,
source: null,
interval: 1000,
i18n: {
noMatches: _('No matches found'),
emptySearch: _('Start typing…'),
Expand All @@ -13,53 +26,125 @@ this.ckan.module('autocomplete', function (jQuery, _) {
}
}
},

/* Sets up the module, binding methods, creating elements etc. Called
* internally by ckan.module.initialize();
*
* Returns nothing.
*/
initialize: function () {
jQuery.proxyAll(this, /_on/, /format/);
this.setupAutoComplete();
},

// Need to keep a reference to this source so we can change the contents.
this.source = jQuery.isArray(this.options.source) ? this.options.source : [];

/* Sets up the auto complete plugin.
*
* Returns nothing.
*/
setupAutoComplete: function () {
this.el.select2({
tags: this._onQuery, /* this needs to be "query" for non tags */
formatResult: this.formatResult,
formatNoMatches: this.formatNoMatches,
formatInputTooShort: this.formatInputTooShort
});
},

/* Looks up the completions for the current search term and passes them
* into the provided callback function.
*
* The results are formatted for use in the select2 autocomplete plugin.
*
* string - The term to search for.
* fn - A callback function.
*
* Examples
*
* module.getCompletions('cake', function (results) {
* results === {results: []}
* });
*
* Returns a jqXHR promise.
*/
getCompletions: function (string, fn) {
var parts = this.options.source.split('?');
var end = parts.pop();
var source = parts.join('?') + string + end;
var source = parts.join('?') + encodeURIComponent(string) + end;
var client = this.sandbox.client;
var options = {format: client.parseCompletionsForPlugin};

client.getCompletions(source, options, fn);
return client.getCompletions(source, options, fn);
},
lookup: function () {

/* Looks up the completions for the provided text but also provides a few
* optimisations. It there is no search term it will automatically set
* an empty array. Ajax requests will also be debounced to ensure that
* the server is not overloaded.
*
* string - The term to search for.
* fn - A callback function.
*
* Returns nothing.
*/
lookup: function (string, fn) {
var module = this;

if (!this._debounced) {
this._debounced = true;
setTimeout(function () {
// Cache the last searched term otherwise we'll end up searching for
// old data.
this._lastTerm = string;

if (string && !this._debounced) {
// Set a timer to prevent the search lookup occurring too often.
this._debounced = setTimeout(function () {
delete module._debounced;
module.getCompletions(module._onLoadCompletions);
}, 300);
} else {
this.typeahead._lookup();

// Cancel the previous request if it hasn't yet completed.
if (module._last) {
module._last.abort();
}

module._last = module.getCompletions(module._lastTerm, fn);
}, this.options.interval);

} else if (!string) {
fn({results: []});
}
},

/* Formatter for the select2 plugin that returns a string for use in the
* results list with the current term emboldened.
*
* Returns a text string.
*/
formatResult: function (state) {
var term = this._lastTerm || null;
return state.text.split(term).join(term && term.bold());
},

/* Formatter for the select2 plugin that returns a string used when
* the filter has no matches.
*
* Returns a text string.
*/
formatNoMatches: function (term) {
return !term ? this.i18n('emptySearch') : this.i18n('noMatches');
},

/* Formatter used by the select2 plugin that returns a string when the
* input is too short.
*
* Returns a string.
*/
formatInputTooShort: function (term, min) {
return this.i18n('inputTooShort', min);
},

/* Callback triggered when the select2 plugin needs to make a request.
*
* Returns nothing.
*/
_onQuery: function (options) {
this.getCompletions(options.term, options.callback);
},
_onLoadCompletions: function (items) {
var args = [0, this.source.length].concat(items);
this.source.splice.apply(this.source, args);
this.typeahead._lookup();
this.lookup(options.term, options.callback);
}
};
});
172 changes: 172 additions & 0 deletions ckan/public/base/test/spec/modules/autocomplete.spec.js
@@ -0,0 +1,172 @@
/*globals describe beforeEach afterEach it assert sinon ckan jQuery */
describe('ckan.modules.AutocompleteModule()', function () {
var Autocomplete = ckan.module.registry['autocomplete'];

beforeEach(function () {
// Stub select2 plugin if loaded.
if (jQuery.fn.select2) {
this.select2 = sinon.stub(jQuery.fn, 'select2');
} else {
this.select2 = jQuery.fn.select2 = sinon.spy();
}

this.el = document.createElement('input');
this.sandbox = ckan.sandbox();
this.sandbox.body = this.fixture;
this.module = new Autocomplete(this.el, {}, this.sandbox);
});

afterEach(function () {
this.module.teardown();

if (this.select2.restore) {
this.select2.restore();
} else {
delete jQuery.fn.select2;
}
});

describe('.initialize()', function () {
it('should bind callback methods to the module', function () {
var target = sinon.stub(jQuery, 'proxyAll');

this.module.initialize();

assert.called(target);
assert.calledWith(target, this.module, /_on/, /format/);

target.restore();
});

it('should setup the autocomplete plugin', function () {
var target = sinon.stub(this.module, 'setupAutoComplete');

this.module.initialize();

assert.called(target);
});
});

describe('.setupAutoComplete()', function () {
it('should initialize the autocomplete plugin', function () {
this.module.setupAutoComplete();

assert.called(this.select2);
assert.calledWith(this.select2, {
tags: this.module._onQuery,
formatResult: this.module.formatResult,
formatNoMatches: this.module.formatNoMatches,
formatInputTooShort: this.module.formatInputTooShort
});
});
});

describe('.getCompletions(term, fn)', function () {
beforeEach(function () {
this.term = 'term';
this.module.options.source = 'http://example.com?term=?';

this.target = sinon.stub(this.sandbox.client, 'getCompletions');
});

it('should get the completions from the client', function () {
this.module.getCompletions(this.term);
assert.called(this.target);
});

it('should replace the last ? in the source url with the term', function () {
this.module.getCompletions(this.term);
assert.calledWith(this.target, 'http://example.com?term=term');
});

it('should escape special characters in the term', function () {
this.module.getCompletions('term with spaces');
assert.calledWith(this.target, 'http://example.com?term=term%20with%20spaces');
});

it('should set the formatter to work with the plugin', function () {
this.module.getCompletions(this.term);
assert.calledWith(this.target, 'http://example.com?term=term', {
format: this.sandbox.client.parseCompletionsForPlugin
});
});
});

describe('.lookup(term, fn)', function () {
beforeEach(function () {
sinon.stub(this.module, 'getCompletions');
this.target = sinon.spy();
});

it('should set the _lastTerm property', function () {
this.module.lookup('term');
assert.equal(this.module._lastTerm, 'term');
});

it('should call the fn immediately if there is no term', function () {
this.module.lookup('', this.target);
assert.called(this.target);
assert.calledWith(this.target, {results: []});
});

it('should debounce the request if there is a term');
it('should cancel the last request');
});

describe('.formatResult(state)', function () {
beforeEach(function () {
this.module._lastTerm = 'term';
});

it('should return the string with the last term wrapped in bold tags', function () {
var target = this.module.formatResult({id: 'we have termites', text: 'we have termites'});
assert.equal(target, 'we have <b>term</b>ites');
});

it('should return the string with each instance of the term wrapped in bold tags', function () {
var target = this.module.formatResult({id: 'we have a termite terminology', text: 'we have a termite terminology'});
assert.equal(target, 'we have a <b>term</b>ite <b>term</b>inology');
});

it('should return the term if there is no last term saved', function () {
delete this.module._lastTerm;
var target = this.module.formatResult({id: 'we have a termite terminology', text: 'we have a termite terminology'});
assert.equal(target, 'we have a termite terminology');
});
});

describe('.formatNoMatches(term)', function () {
it('should return the no matches string if there is a term', function () {
var target = this.module.formatNoMatches('term');
assert.equal(target, 'No matches found');
});

it('should return the empty string if there is no term', function () {
var target = this.module.formatNoMatches('');
assert.equal(target, 'Start typing…');
});
});

describe('.formatInputTooShort(term, min)', function () {
it('should return the plural input too short string', function () {
var target = this.module.formatInputTooShort('term', 2);
assert.equal(target, 'Input is too short, must be at least 2 characters');
});

it('should return the singular input too short string', function () {
var target = this.module.formatInputTooShort('term', 1);
assert.equal(target, 'Input is too short, must be at least one character');
});
});

describe('._onQuery(options)', function () {
it('should lookup the current term with the callback', function () {
var target = sinon.stub(this.module, 'lookup');

this.module._onQuery({term: 'term', callback: 'callback'});

assert.called(target);
assert.calledWith(target, 'term', 'callback');
});
});
});

0 comments on commit 85d9b38

Please sign in to comment.