Permalink
Browse files

Taking care of the critical concurrency todo. Using a request queue t…

…o ensure the user only sees the lastest response.
  • Loading branch information...
1 parent b40eb93 commit df3feae496c9a78a47a7562da802b25f38bfd67e @ksykulev committed Jan 27, 2013
Showing with 252 additions and 7 deletions.
  1. +50 −2 chosen.ajaxaddition.jquery.js
  2. +51 −3 example/chosen.ajaxaddition.jquery.js
  3. +1 −0 example/index.html
  4. BIN test/img/l04der.gif
  5. +150 −2 test/main.js
@@ -1,14 +1,22 @@
/*jshint forin:true, noarg:true, noempty:true, eqeqeq:false, bitwise:true, strict:true, undef:true, curly:true, browser:true, indent:4, maxerr:50, onevar:false, nomen:false, regexp:false, plusplus:false, newcap:true */
(function ($) {
"use strict";
+ //Thanks John - http://ejohn.org/blog/javascript-array-remove/
+ var arrayRemove = function (from, to) {
+ var rest = this.slice((to || from) + 1 || this.length);
+ this.length = from < 0 ? this.length + from : from;
+ return this.push.apply(this, rest);
+ };
$.fn.ajaxChosen = function (ajaxOptions, options, chosenOptions) {
var select = $(this),
chosen,
- throttle = false,
keyRight,
input,
inputBG,
callback,
+ throttle = false,
+ requestQueue = [],
+ typing = false,
loadingImg = '/img/loading.gif';
if ($('option', select).length === 0) {
@@ -30,7 +38,43 @@
//replace with our success callback
ajaxOptions.success = function (data, textStatus, jqXHR) {
var items = data,
- selected;
+ selected,
+ requestQueueLength = requestQueue.length,
+ old = false,
+ keep = false;
+ if (typing) {
+ //server returned a response, but it's about to become an older response
+ //so discard it and wait until the user is done typing
+ requestQueue.shift();
+ return false;
+ }
+ if (requestQueueLength > 1) {
+ $.each(requestQueue, function (idx, elem) {
+ if (data.q === elem) {
+ if (idx !== (requestQueueLength - 1)) {
+ //found an older response, remove it from the queue and wait for newest response
+ old = true;
+ arrayRemove.call(requestQueue, idx);
+ } else {
+ //this handles the out of order request/response
+ //last request came in first, and we want to keep it
+ keep = true;
+ //remove all the other older requests
+ requestQueue.length = 0;
+ }
+ return false;
+ }
+ });
+ //if we found an old response or we found the newest response and want to keep processing
+ if (old || !keep) { return false; }
+ } else {
+ //only 1 request was made by the user remove it from queue and continue processing
+ if (typeof requestQueue.shift() === 'undefined') {
+ //If all the old responses have been discarded because we've received the new one already
+ return false;
+ }
+ }
+
//if additional processing needs to occur on the returned json
if ('processItems' in options && $.isFunction(options.processItems)) {
items = options.processItems(data);
@@ -126,6 +170,8 @@
return false;
}
+ typing = true;
+
//hide no results
$('.no-results', chosen).hide();
//add query to data
@@ -152,6 +198,8 @@
//throttle that bitch, so we don't kill the server
if (throttle) { clearTimeout(throttle); }
throttle = setTimeout(function () {
+ requestQueue.push(q);
+ typing = false;
$.ajax(ajaxOptions);
}, 700);
});
@@ -1,14 +1,22 @@
/*jshint forin:true, noarg:true, noempty:true, eqeqeq:false, bitwise:true, strict:true, undef:true, curly:true, browser:true, indent:4, maxerr:50, onevar:false, nomen:false, regexp:false, plusplus:false, newcap:true */
(function ($) {
"use strict";
+ //Thanks John - http://ejohn.org/blog/javascript-array-remove/
+ var arrayRemove = function (from, to) {
+ var rest = this.slice((to || from) + 1 || this.length);
+ this.length = from < 0 ? this.length + from : from;
+ return this.push.apply(this, rest);
+ };
$.fn.ajaxChosen = function (ajaxOptions, options, chosenOptions) {
var select = $(this),
chosen,
- throttle = false,
keyRight,
input,
inputBG,
callback,
+ throttle = false,
+ requestQueue = [],
+ typing = false,
loadingImg = '/img/loading.gif';
if ($('option', select).length === 0) {
@@ -22,15 +30,51 @@
}
chosen = select.next();
input = $('input', chosen);
- inputBG = input.get(0).style.background;
+ inputBG = input.get(0) ? input.get(0).style.background : '';
//copy out success callback
if ('success' in ajaxOptions && $.isFunction(ajaxOptions.success)) {
callback = ajaxOptions.success;
}
//replace with our success callback
ajaxOptions.success = function (data, textStatus, jqXHR) {
var items = data,
- selected;
+ selected,
+ requestQueueLength = requestQueue.length,
+ old = false,
+ keep = false;
+ if (typing) {
+ //server returned a response, but it's about to become an older response
+ //so discard it and wait until the user is done typing
+ requestQueue.shift();
+ return false;
+ }
+ if (requestQueueLength > 1) {
+ $.each(requestQueue, function (idx, elem) {
+ if (data.q === elem) {
+ if (idx !== (requestQueueLength - 1)) {
+ //found an older response, remove it from the queue and wait for newest response
+ old = true;
+ arrayRemove.call(requestQueue, idx);
+ } else {
+ //this handles the out of order request/response
+ //last request came in first, and we want to keep it
+ keep = true;
+ //remove all the other older requests
+ requestQueue.length = 0;
+ }
+ return false;
+ }
+ });
+ //if we found an old response or we found the newest response and want to keep processing
+ if (old || !keep) { return false; }
+ } else {
+ //only 1 request was made by the user remove it from queue and continue processing
+ if (typeof requestQueue.shift() === 'undefined') {
+ //If all the old responses have been discarded because we've received the new one already
+ return false;
+ }
+ }
+
//if additional processing needs to occur on the returned json
if ('processItems' in options && $.isFunction(options.processItems)) {
items = options.processItems(data);
@@ -126,6 +170,8 @@
return false;
}
+ typing = true;
+
//hide no results
$('.no-results', chosen).hide();
//add query to data
@@ -152,6 +198,8 @@
//throttle that bitch, so we don't kill the server
if (throttle) { clearTimeout(throttle); }
throttle = setTimeout(function () {
+ requestQueue.push(q);
+ typing = false;
$.ajax(ajaxOptions);
}, 700);
});
View
@@ -7,6 +7,7 @@
</style>
</head>
<body>
+ <a href="https://github.com/ksykulev/chosen-ajax-addition"><img style="position: absolute; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_green_007200.png" alt="Fork me on GitHub"></a>
<p>Type one of the 50 states to begin</p>
<h2>Single Select</h2>
<select id="states-select" name="states-select">
View
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
View
@@ -85,7 +85,7 @@ describe('chosen.ajaxaddition', function(){
type: 'POST',
url:'/search'
},{
- loadingImg: '../vendor/loading.gif'
+ loadingImg: 'img/l04der.gif'
}).next();
chosen.trigger('click');
input = $('input', chosen).val('banan');
@@ -94,7 +94,7 @@ describe('chosen.ajaxaddition', function(){
key = $.Event('keyup');
key.which = 65;
input.trigger(key);
- expect(input.css('background-image')).to.match(/loading\.gif/i);
+ expect(input.css('background-image')).to.match(/l04der\.gif/i);
});
it('should apply the processItems function to response data', function(){
var chosen,
@@ -516,4 +516,152 @@ describe('chosen.ajaxaddition', function(){
expect(chosen.hasClass('chzn-container-single-nosearch')).to.be.false;
});
});
+ describe('a request queue to ensure the user only sees the last response', function(){
+ beforeEach(function(){
+ this.clock = sinon.useFakeTimers();
+ this.xhr = sinon.useFakeXMLHttpRequest();
+ var requests = this.requests = [];
+ this.xhr.onCreate = function (xhr) { requests.push(xhr) };
+ });
+ afterEach(function(){
+ this.clock.restore();
+ this.xhr.restore();
+ });
+ it('during typing', function(){
+ var chosen,
+ input,
+ key;
+
+ chosen = $('select', space).ajaxChosen({
+ dataType: 'json',
+ type: 'POST',
+ url: '/search'
+ },{}).next();
+ chosen.trigger('click');
+
+ //first request
+ input = $('input', chosen).val('monkey');
+ key = $.Event('keyup');
+ key.which = 32;
+ input.trigger(key);
+ this.clock.tick(750);
+ //server begins processing request 1
+ expect(input.val()).to.equal('monkey');
+ expect(this.requests).to.have.length(1);
+
+ //second request
+ input.val('banana');
+ key = $.Event('keyup');
+ key.which = 32;
+ input.trigger(key);
+ this.clock.tick(350);//not enough time to start processing the request
+ expect(input.val()).to.equal('banana');
+ expect(this.requests).to.have.length(1);//see still one request
+
+ //request 1 comes back while we're still typing! Dam the server is fast
+ this.requests[0].respond(200, { "Content-Type": "application/json" }, '{ "q": "monkey", "results": [{"id":1, "text":"first monkey"}]}');
+
+ //discard first response keep newly typed word and ajax chosen should still look like it's still processing
+ expect(input.val()).to.equal('banana');
+ expect(input.css('background-image')).to.match(/loading\.gif/i);
+ //the rest of the clock ticks down so we should now fire off the second request
+ this.clock.tick(400);
+ expect(this.requests).to.have.length(2);
+ expect(input.val()).to.equal('banana');
+ //yup still waiting for the server to respond
+ expect(input.css('background-image')).to.match(/loading\.gif/i);
+
+ //response 2 comes back and banana is selected
+ this.requests[1].respond(200, { "Content-Type": "application/json" }, '{ "q": "banana", "results": [{"id":1, "text":"banana bunch"}]}');
+ expect(input.val()).to.equal('banana');
+ expect(input.css('background-image')).to.not.match(/loading\.gif/i);
+ });
+ it('in order', function(){
+ var chosen,
+ input,
+ key;
+
+ chosen = $('select', space).ajaxChosen({
+ dataType: 'json',
+ type: 'POST',
+ url: '/search'
+ },{}).next();
+ chosen.trigger('click');
+
+ //first request
+ input = $('input', chosen).val('monkey');
+ key = $.Event('keyup');
+ key.which = 32;
+ input.trigger(key);
+ this.clock.tick(750);
+ //server begins processing request 1
+ expect(input.val()).to.equal('monkey');
+ expect(this.requests).to.have.length(1);
+
+ //second request
+ input.val('banana');
+ key = $.Event('keyup');
+ key.which = 32;
+ input.trigger(key);
+ this.clock.tick(750);
+ //server begins processing request 2
+ expect(input.val()).to.equal('banana');
+ expect(this.requests).to.have.length(2);
+
+ //response 1 comes back
+ this.requests[0].respond(200, { "Content-Type": "application/json" }, '{ "q": "monkey", "results": [{"id":1, "text":"first monkey"}]}');
+
+ //discard first response keep newly typed word and ajax chosen should still look like it's still processing
+ expect(input.val()).to.equal('banana');
+ expect(input.css('background-image')).to.match(/loading\.gif/i);
+
+ //response 2 comes back and banana is selected
+ this.requests[1].respond(200, { "Content-Type": "application/json" }, '{ "q": "banana", "results": [{"id":1, "text":"banana bunch"}]}');
+ expect(input.val()).to.equal('banana');
+ expect(input.css('background-image')).to.not.match(/loading\.gif/i);
+ });
+ it('out of order', function(){
+ var chosen,
+ input,
+ key;
+
+ chosen = $('select', space).ajaxChosen({
+ dataType: 'json',
+ type: 'POST',
+ url: '/search'
+ },{}).next();
+ chosen.trigger('click');
+
+ //first request
+ input = $('input', chosen).val('monkey');
+ key = $.Event('keyup');
+ key.which = 32;
+ input.trigger(key);
+ this.clock.tick(750);
+ //server begins processing request 1
+ expect(input.val()).to.equal('monkey');
+ expect(this.requests).to.have.length(1);
+
+ //second request
+ input.val('banana');
+ key = $.Event('keyup');
+ key.which = 32;
+ input.trigger(key);
+ this.clock.tick(750);
+ //server begins processing request 2
+ expect(input.val()).to.equal('banana');
+ expect(this.requests).to.have.length(2);
+
+ //response 2 comes back first and banana is selected
+ this.requests[1].respond(200, { "Content-Type": "application/json" }, '{ "q": "banana", "results": [{"id":1, "text":"banana bunch"}]}');
+ expect(input.val()).to.equal('banana');
+ expect(input.css('background-image')).to.not.match(/loading\.gif/i);
+
+ //response 1 comes back out of order
+ this.requests[0].respond(200, { "Content-Type": "application/json" }, '{ "q": "monkey", "results": [{"id":1, "text":"first monkey"}]}');
+ //discard first response keep newly typed word and result set
+ expect(input.val()).to.equal('banana');
+ expect(input.css('background-image')).to.not.match(/loading\.gif/i);
+ });
+ });
});

0 comments on commit df3feae

Please sign in to comment.