From 1a6527f7e57aa476f2ede36ed7f9b6f6eba91088 Mon Sep 17 00:00:00 2001 From: Michael Benford Date: Sat, 30 Nov 2013 02:48:59 -0200 Subject: [PATCH] feat(autocomplete): Implemented debounce delay Added a debounce delay option to the autocomplete directive so it doesn't call the source function too many times within a short period of time. Closes #19. --- src/auto-complete.js | 59 ++++++++++++++------- test/auto-complete.spec.js | 102 +++++++++++++++++++++++++++++-------- test/test-page.html | 3 +- 3 files changed, 124 insertions(+), 40 deletions(-) diff --git a/src/auto-complete.js b/src/auto-complete.js index 5f0a6621..0314bbde 100644 --- a/src/auto-complete.js +++ b/src/auto-complete.js @@ -8,18 +8,37 @@ * @description * Provides autocomplete support for the tagsInput directive. * - * @param {expression} source Callback that will be called for every keystroke and will be provided with the current - * input's value. Must return a promise. + * @param {expression} source Expression to evaluate upon changing the input content. The input value is available as $text. + * The result of the expression must be a promise that resolves to an array of strings. */ -angular.module('tags-input').directive('autoComplete', function($document) { - function SuggestionList(loadFn) { - var self = {}; +angular.module('tags-input').directive('autoComplete', function($document, $interpolate, $timeout) { + function initializeOptions(scope, attrs, options) { + var converters = {}; + converters[String] = function(value) { return value; }; + converters[Number] = function(value) { return parseInt(value, 10); }; + converters[Boolean] = function(value) { return value === 'true'; }; + converters[RegExp] = function(value) { return new RegExp(value); }; + + scope.options = {}; + + angular.forEach(options, function(value, key) { + var interpolatedValue = attrs[key] && $interpolate(attrs[key])(scope.$parent), + converter = converters[options[key].type]; + + scope.options[key] = interpolatedValue ? converter(interpolatedValue) : options[key].defaultValue; + }); + } + + function SuggestionList(loadFn, options) { + var self = {}, debouncedLoadId; self.reset = function() { self.items = []; self.visible = false; self.index = -1; self.selected = null; + + $timeout.cancel(debouncedLoadId); }; self.show = function() { self.selected = null; @@ -29,16 +48,15 @@ angular.module('tags-input').directive('autoComplete', function($document) { self.visible = false; }; self.load = function(text) { - if (self.selected === text) { - return; - } - - loadFn({ $text: text }).then(function(items) { - self.items = items; - if (items.length > 0) { - self.show(); - } - }); + $timeout.cancel(debouncedLoadId); + debouncedLoadId = $timeout(function() { + loadFn({ $text: text }).then(function(items) { + self.items = items; + if (items.length > 0) { + self.show(); + } + }); + }, options.debounceDelay, false); }; self.selectNext = function() { self.select(++self.index); @@ -76,10 +94,15 @@ angular.module('tags-input').directive('autoComplete', function($document) { '', link: function(scope, element, attrs, tagsInputCtrl) { var hotkeys = [KEYS.enter, KEYS.tab, KEYS.escape, KEYS.up, KEYS.down], - suggestionList = new SuggestionList(scope.source), + suggestionList, tagsInput, input; + + initializeOptions(scope, attrs, { + debounceDelay: { type: Number, defaultValue: 100 } + }); - tagsInput = tagsInputCtrl.registerAutocomplete(), - input = tagsInput.input; + suggestionList = new SuggestionList(scope.source, scope.options); + tagsInput = tagsInputCtrl.registerAutocomplete(); + input = tagsInput.input; scope.suggestionList = suggestionList; diff --git a/test/auto-complete.spec.js b/test/auto-complete.spec.js index 79a16b1f..ea82aec3 100644 --- a/test/auto-complete.spec.js +++ b/test/auto-complete.spec.js @@ -2,16 +2,17 @@ 'use strict'; describe('autocomplete-directive', function () { - var $compile, $scope, $q, - parentCtrl, element, input, suggestionList, deferred, inputChangeHandler, onTagAddedHandler; + var $compile, $scope, $q, $timeout, + parentCtrl, element, isolateScope, input, suggestionList, deferred, inputChangeHandler, onTagAddedHandler; beforeEach(function () { module('tags-input'); - inject(function($rootScope, _$compile_, _$q_) { + inject(function($rootScope, _$compile_, _$q_, _$timeout_) { $scope = $rootScope; $compile = _$compile_; $q = _$q_; + $timeout = _$timeout_; }); deferred = $q.defer(); @@ -21,7 +22,7 @@ describe('autocomplete-directive', function () { }); function compile() { - var parent, tagsInput; + var parent, tagsInput, options; input = angular.element(''); input.changeValue = jasmine.createSpy(); @@ -39,13 +40,15 @@ describe('autocomplete-directive', function () { spyOn(parentCtrl, 'registerAutocomplete').andReturn(tagsInput); - element = angular.element(''); + options = jQuery.makeArray(arguments).join(' '); + element = angular.element(''); parent.append(element); $compile(element)($scope); $scope.$digest(); - - suggestionList = element.isolateScope().suggestionList; + + isolateScope = element.isolateScope(); + suggestionList = isolateScope.suggestionList; } function resolve(items) { @@ -87,6 +90,7 @@ describe('autocomplete-directive', function () { function loadSuggestions(items) { suggestionList.load(''); + $timeout.flush(); resolve(items); } @@ -223,25 +227,12 @@ describe('autocomplete-directive', function () { suggestionList.select(0); // Act - element.isolateScope().addSuggestion(); + isolateScope.addSuggestion(); // Assert expect(suggestionList.selected).toBeNull(); }); - it('calls the load function for every key pressed passing the input content', function() { - // Act - changeInputValue('A'); - changeInputValue('AB'); - changeInputValue('ABC'); - - // Assert - expect($scope.loadItems.callCount).toBe(3); - expect($scope.loadItems.calls[0].args[0]).toBe('A'); - expect($scope.loadItems.calls[1].args[0]).toBe('AB'); - expect($scope.loadItems.calls[2].args[0]).toBe('ABC'); - }); - it('does not call the load function after adding the selected suggestion to the input field', function() { // Arrange loadSuggestions(['Item1', 'Item2']); @@ -500,6 +491,75 @@ describe('autocomplete-directive', function () { expect(event.isPropagationStopped()).toBe(false); }); }); + + describe('debounce-delay option', function () { + it('doesn\'t call the load function immediately', function () { + // Arrange + compile('debounce-delay="100"'); + + // Act + changeInputValue('A'); + changeInputValue('AB'); + changeInputValue('ABC'); + + // Assert + expect($scope.loadItems).not.toHaveBeenCalled(); + }); + + it('calls the load function only after a delay has passed', function() { + // Arrange + compile('debounce-delay="100"'); + + // Act + changeInputValue('A'); + changeInputValue('AB'); + changeInputValue('ABC'); + + $timeout.flush(); + + // Assert + expect($scope.loadItems).toHaveBeenCalledWith('ABC'); + }); + + it('doesn\'t call the load function when the reset method is called', function() { + // Arrange + compile(); + changeInputValue('A'); + + // Act + suggestionList.reset(); + $timeout.flush(); + + // Assert + expect($scope.loadItems).not.toHaveBeenCalled(); + + }); + + it('initializes the option to 100 milliseconds', function () { + // Arrange/Act + compile(); + + // Assert + expect(isolateScope.options.debounceDelay).toBe(100); + }); + + it('sets the option given a static string', function() { + // Arrange/Act + compile('debounce-delay="1000"'); + + // Assert + expect(isolateScope.options.debounceDelay).toBe(1000); + }); + + it('sets the option given an interpolated string', function() { + // Arrange/Act + $scope.value = 1000; + compile('debounce-delay="{{ value }}"'); + + // Assert + expect(isolateScope.options.debounceDelay).toBe(1000); + }); + }); }); })(); diff --git a/test/test-page.html b/test/test-page.html index ed53c81d..a4379e12 100644 --- a/test/test-page.html +++ b/test/test-page.html @@ -8,7 +8,7 @@ - +