Skip to content
This repository has been archived by the owner on Nov 22, 2021. It is now read-only.

Commit

Permalink
feat(autoComplete): Add custom template support
Browse files Browse the repository at this point in the history
Add custom template support for the autoComplete directive so each
suggestion can be freely customized. Introduce a new option called
template that holds either a URL to an HTML file or an id of an inline
script tag. The template, once rendered, is provided with both a special
property containing the matching data and a helper object containing
useful functions (e.g. text highlighting).

Closes #99
  • Loading branch information
mbenford committed Mar 12, 2015
1 parent 4a34d88 commit b550b11
Show file tree
Hide file tree
Showing 9 changed files with 253 additions and 80 deletions.
3 changes: 2 additions & 1 deletion Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ module.exports = function(grunt) {
'src/init.js',
'src/tags-input.js',
'src/auto-complete.js',
'src/auto-complete-match.js',
'src/transclude-append.js',
'src/autosize.js',
'src/bind-attrs.js',
Expand All @@ -48,7 +49,7 @@ module.exports = function(grunt) {
}
},
html: {
src: ['templates/tags-input.html', 'templates/auto-complete.html'],
src: ['templates/tags-input.html', 'templates/auto-complete.html', 'templates/auto-complete-match.html'],
out: 'tmp/templates.js'
},
zip: {
Expand Down
36 changes: 36 additions & 0 deletions src/auto-complete-match.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use strict';

/**
* @ngdoc directive
* @name tiAutocompleteMatch
* @module ngTagsInput
*
* @description
* Represents an autocomplete match. Used internally by the tagsInput directive.
*/
tagsInput.directive('tiAutocompleteMatch', function($sce, tiUtil) {
return {
restrict: 'E',
require: '^autoComplete',
template: '<ng-include src="template"></ng-include>',
scope: { data: '=' },
link: function(scope, element, attrs, autoCompleteCtrl) {
var autoComplete = autoCompleteCtrl.registerAutocompleteMatch(),
options = autoComplete.getOptions();

scope.template = options.template;

scope.util = {
highlight: function(text) {
if (options.highlightMatchedText) {
text = tiUtil.safeHighlight(text, autoComplete.getQuery());
}
return $sce.trustAsHtml(text);
},
getDisplayText: function() {
return tiUtil.safeToString(scope.data[options.displayProperty || options.tagsInput.displayProperty]);
}
};
}
};
});
61 changes: 28 additions & 33 deletions src/auto-complete.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,19 @@
* gains focus. The current input value is available as $query.
* @param {boolean=} [selectFirstMatch=true] Flag indicating that the first match will be automatically selected once
* the suggestion list is shown.
* @param {string=} [template=] URL or id of a custom template for rendering each element of the autocomplete list.
*/
tagsInput.directive('autoComplete', function($document, $timeout, $sce, $q, tagsInputConfig, tiUtil) {
function SuggestionList(loadFn, options) {
var self = {}, getDifference, lastPromise, getIdProperty;
var self = {}, getDifference, lastPromise, getTagId;

getIdProperty = function() {
return options.tagsInput.keyProperty || options.displayProperty || options.tagsInput.displayProperty;
getTagId = function() {
return options.tagsInput.keyProperty || options.tagsInput.displayProperty;
};

getDifference = function(array1, array2) {
return array1.filter(function(item) {
return !tiUtil.findInObjectArray(array2, item, getIdProperty());
return !tiUtil.findInObjectArray(array2, item, getTagId());
});
};

Expand Down Expand Up @@ -72,7 +73,7 @@ tagsInput.directive('autoComplete', function($document, $timeout, $sce, $q, tags
return;
}

items = tiUtil.makeObjectArray(items.data || items, getIdProperty());
items = tiUtil.makeObjectArray(items.data || items, getTagId());
items = getDifference(items, tags);
self.items = items.slice(0, options.maxResultsToShow);

Expand Down Expand Up @@ -112,11 +113,9 @@ tagsInput.directive('autoComplete', function($document, $timeout, $sce, $q, tags
require: '^tagsInput',
scope: { source: '&' },
templateUrl: 'ngTagsInput/auto-complete.html',
link: function(scope, element, attrs, tagsInputCtrl) {
var hotkeys = [KEYS.enter, KEYS.tab, KEYS.escape, KEYS.up, KEYS.down],
suggestionList, tagsInput, options, getItem, getDisplayText, shouldLoadSuggestions;

tagsInputConfig.load('autoComplete', scope, attrs, {
controller: function($scope, $attrs) {
tagsInputConfig.load('autoComplete', $scope, $attrs, {
template: [String, 'ngTagsInput/auto-complete-match.html'],
debounceDelay: [Number, 100],
minLength: [Number, 3],
highlightMatchedText: [Boolean, true],
Expand All @@ -128,27 +127,32 @@ tagsInput.directive('autoComplete', function($document, $timeout, $sce, $q, tags
displayProperty: [String, '']
});

options = scope.options;

tagsInput = tagsInputCtrl.registerAutocomplete();
options.tagsInput = tagsInput.getOptions();

suggestionList = new SuggestionList(scope.source, options);
$scope.suggestionList = new SuggestionList($scope.source, $scope.options);

getItem = function(item) {
return item[options.displayProperty || options.tagsInput.displayProperty];
this.registerAutocompleteMatch = function() {
return {
getOptions: function() {
return $scope.options;
},
getQuery: function() {
return $scope.suggestionList.query;
}
};
};
},
link: function(scope, element, attrs, tagsInputCtrl) {
var hotkeys = [KEYS.enter, KEYS.tab, KEYS.escape, KEYS.up, KEYS.down],
suggestionList = scope.suggestionList,
tagsInput = tagsInputCtrl.registerAutocomplete(),
options = scope.options,
shouldLoadSuggestions;

getDisplayText = function(item) {
return tiUtil.safeToString(getItem(item));
};
options.tagsInput = tagsInput.getOptions();

shouldLoadSuggestions = function(value) {
return value && value.length >= options.minLength || !value && options.loadOnEmpty;
};

scope.suggestionList = suggestionList;

scope.addSuggestionByIndex = function(index) {
suggestionList.select(index);
scope.addSuggestion();
Expand All @@ -167,17 +171,8 @@ tagsInput.directive('autoComplete', function($document, $timeout, $sce, $q, tags
return added;
};

scope.highlight = function(item) {
var text = getDisplayText(item);
text = tiUtil.encodeHTML(text);
if (options.highlightMatchedText) {
text = tiUtil.safeHighlight(text, tiUtil.encodeHTML(suggestionList.query));
}
return $sce.trustAsHtml(text);
};

scope.track = function(item) {
return options.tagsInput.keyProperty ? item[options.tagsInput.keyProperty] : getItem(item);
return item[options.tagsInput.keyProperty || options.tagsInput.displayProperty];
};

tagsInput
Expand Down
8 changes: 6 additions & 2 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* @module ngTagsInput
*
* @description
* Helper methods used internally by the directive. Should not be used directly from user code.
* Helper methods used internally by the directive. Should not be called directly from user code.
*/
tagsInput.factory('tiUtil', function($timeout) {
var self = {};
Expand Down Expand Up @@ -53,6 +53,9 @@ tagsInput.factory('tiUtil', function($timeout) {
return str.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
}

str = self.encodeHTML(str);
value = self.encodeHTML(value);

var expression = new RegExp('&[^;]+;|' + escapeRegexChars(value), 'gi');
return str.replace(expression, function(match) {
return match === value ? '<em>' + value + '</em>' : match;
Expand All @@ -64,7 +67,8 @@ tagsInput.factory('tiUtil', function($timeout) {
};

self.encodeHTML = function(value) {
return value.replace(/&/g, '&amp;')
return self.safeToString(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
};
Expand Down
1 change: 1 addition & 0 deletions templates/auto-complete-match.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<span ng-bind-html="util.highlight(util.getDisplayText())"></span>
7 changes: 4 additions & 3 deletions templates/auto-complete.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<div class="autocomplete" ng-show="suggestionList.visible">
<div class="autocomplete" ng-if="suggestionList.visible">
<ul class="suggestion-list">
<li class="suggestion-item"
ng-repeat="item in suggestionList.items track by track(item)"
ng-class="{selected: item == suggestionList.selected}"
ng-click="addSuggestionByIndex($index)"
ng-mouseenter="suggestionList.select($index)"
ng-bind-html="highlight(item)"></li>
ng-mouseenter="suggestionList.select($index)">
<ti-autocomplete-match data="item"></ti-autocomplete-match>
</li>
</ul>
</div>
124 changes: 113 additions & 11 deletions test/auto-complete.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,6 @@ describe('autoComplete directive', function() {
};
$scope.loadItems = jasmine.createSpy().and.returnValue(deferred.promise);

compile();
});

function compile() {
var parent, options;

tagsInput = {
changeInputValue: jasmine.createSpy(),
addTag: jasmine.createSpy(),
Expand All @@ -47,6 +41,12 @@ describe('autoComplete directive', function() {
})
};

compile();
});

function compile() {
var parent, options;

parent = $compile('<tags-input ng-model="whatever"></tags-input>')($scope);
$scope.$digest();

Expand Down Expand Up @@ -94,11 +94,11 @@ describe('autoComplete directive', function() {
}

function getSuggestionText(index) {
return getSuggestion(index).html();
return getSuggestion(index).find('ti-autocomplete-match > ng-include > span').html();
}

function isSuggestionsBoxVisible() {
return !getSuggestionsBox().hasClass('ng-hide');
return !!getSuggestionsBox().length;
}

function generateSuggestions(count) {
Expand Down Expand Up @@ -181,6 +181,25 @@ describe('autoComplete directive', function() {
expect(getSuggestionText(1)).toBe('Item2');
});

it('renders all elements returned by the load function using the provided display-property option', function() {
// Arrange
tagsInput.getOptions.and.returnValue({ displayProperty: 'label' });
compile();

// Act
loadSuggestions([
{ label: 'Item1' },
{ label: 'Item2' },
{ label: 'Item3' },
]);

// Assert
expect(getSuggestions().length).toBe(3);
expect(getSuggestionText(0)).toBe('Item1');
expect(getSuggestionText(1)).toBe('Item2');
expect(getSuggestionText(2)).toBe('Item3');
});

it('shows the suggestions list when there are items to show', function() {
// Act
loadSuggestions(1);
Expand Down Expand Up @@ -1104,9 +1123,9 @@ describe('autoComplete directive', function() {

// Act
loadSuggestions([
{ label: 'Item1' },
{ label: 'Item2' },
{ label: 'Item3' }
{ text: '1', label: 'Item1' },
{ text: '2', label: 'Item2' },
{ text: '3', label: 'Item3' }
]);

// Assert
Expand All @@ -1116,4 +1135,87 @@ describe('autoComplete directive', function() {
expect(getSuggestionText(2)).toBe('Item3');
});
});

describe('template option', function() {
var $templateCache;

function getSuggestionContent(index) {
return getSuggestion(index)
.find('ti-autocomplete-match > ng-include')
.children()
.removeAttr('class')
.parent()
.html();
}

function getSuggestionScope(index) {
return getSuggestion(index)
.find('ti-autocomplete-match > ng-include')
.children()
.scope();
}

beforeEach(function() {
inject(function(_$templateCache_) {
$templateCache = _$templateCache_;
});
});

it('initializes the option to the default template file', function() {
expect(isolateScope.options.template).toBe('ngTagsInput/auto-complete-match.html');
});

it('loads and uses the provided template', function() {
// Arrange
$templateCache.put('customTemplate', '<span>{{data.id}}</span><span>{{data.text}}</span>');
compile('template="customTemplate"');

// Act
loadSuggestions([
{ id: 1, text: 'Item1' },
{ id: 2, text: 'Item2' },
{ id: 3, text: 'Item3' }
]);

// Assert
expect(getSuggestionContent(0)).toBe('<span>1</span><span>Item1</span>');
expect(getSuggestionContent(1)).toBe('<span>2</span><span>Item2</span>');
expect(getSuggestionContent(2)).toBe('<span>3</span><span>Item3</span>');
});

it('makes the match data available to the template', function() {
// Arrange
compile();

// Act
loadSuggestions([
{ id: 1, text: 'Item1', image: 'item1.jpg' },
{ id: 2, text: 'Item2', image: 'item2.jpg' },
{ id: 3, text: 'Item3', image: 'item3.jpg' }
]);

// Assert
expect(getSuggestionScope(0).data).toEqual({ id: 1, text: 'Item1', image: 'item1.jpg' });
expect(getSuggestionScope(1).data).toEqual({ id: 2, text: 'Item2', image: 'item2.jpg' });
expect(getSuggestionScope(2).data).toEqual({ id: 3, text: 'Item3', image: 'item3.jpg' });
});

it('makes the util object available to the template', function() {
// Arrange
compile();

// Act
loadSuggestions([
{ text: 'Item1' },
{ text: 'Item2' },
{ text: 'Item3' }
]);

// Assert
var utilObj = { highlight: jasmine.any(Function), getDisplayText: jasmine.any(Function) };
expect(getSuggestionScope(0).util).toEqual(utilObj);
expect(getSuggestionScope(1).util).toEqual(utilObj);
expect(getSuggestionScope(2).util).toEqual(utilObj);
});
});
});
Loading

0 comments on commit b550b11

Please sign in to comment.