From 997db7c9b57098e993b5cd72a09662e97254a709 Mon Sep 17 00:00:00 2001 From: Kasper Lewau Date: Thu, 20 Aug 2015 18:02:36 +0200 Subject: [PATCH 1/3] feat(tests): unit all the tests * Split controller to separate file for test purposes. * Add gulp karma (single run) * Add gulp watch:karma (no single run) Closes #5 --- dist/mention.js | 615 ++++++++++++++++--------------- dist/mention.min.js | 2 +- gulpfile.js | 15 +- karma.conf.js | 23 ++ package.json | 14 + src/mention.es6.js | 311 +--------------- src/mentionController.es6.js | 312 ++++++++++++++++ test/uiMentionController.spec.js | 512 +++++++++++++++++++++++++ test/uiMentionDirective.spec.js | 66 ++++ 9 files changed, 1252 insertions(+), 618 deletions(-) create mode 100644 karma.conf.js create mode 100644 src/mentionController.es6.js create mode 100644 test/uiMentionController.spec.js create mode 100644 test/uiMentionDirective.spec.js diff --git a/dist/mention.js b/dist/mention.js index ed4c051..45737b7 100644 --- a/dist/mention.js +++ b/dist/mention.js @@ -2,9 +2,10 @@ var _slicedToArray = (function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i['return']) _i['return'](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError('Invalid attempt to destructure non-iterable instance'); } }; })(); -angular.module('ui.mention', []).directive('uiMention', function ($q, $timeout, $document) { +angular.module('ui.mention', []).directive('uiMention', function () { return { require: ['ngModel', 'uiMention'], + controller: 'uiMentionController', controllerAs: '$mention', link: function link($scope, $element, $attrs, _ref) { var _ref2 = _slicedToArray(_ref, 2); @@ -13,324 +14,326 @@ angular.module('ui.mention', []).directive('uiMention', function ($q, $timeout, var uiMention = _ref2[1]; uiMention.init(ngModel); - }, - controller: function controller($element, $scope, $attrs) { - var _this2 = this; - - // Beginning of input or preceeded by spaces: @sometext - this.pattern = this.pattern || /(?:\s+|^)@(\w+(?: \w+)?)$/; - this.$element = $element; - this.choices = []; - this.mentions = []; - var ngModel; - - /** - * $mention.init() - * - * Initializes the plugin by setting up the ngModelController properties - * - * @param {ngModelController} model - */ - this.init = function (model) { - var _this = this; - - // Leading whitespace shows up in the textarea but not the preview - $attrs.ngTrim = 'false'; - - ngModel = model; - - ngModel.$parsers.push(function (value) { - // Removes any mentions that aren't used - _this.mentions = _this.mentions.filter(function (mention) { - if (~value.indexOf(_this.label(mention))) return value = value.replace(_this.label(mention), _this.encode(mention)); - }); - - _this.render(value); - - return value; - }); - - ngModel.$formatters.push(function () { - var value = arguments.length <= 0 || arguments[0] === undefined ? '' : arguments[0]; - - // In case the value is a different primitive - value = value.toString(); - - // Removes any mentions that aren't used - _this.mentions = _this.mentions.filter(function (mention) { - if (~value.indexOf(_this.encode(mention))) { - value = value.replace(_this.encode(mention), _this.label(mention)); - return true; - } else { - return false; - } - }); - - return value; - }); - - ngModel.$render = function () { - $element.val(ngModel.$viewValue || ''); - _this.render(); - }; - }; - - /** - * $mention.render() - * - * Renders the syntax-encoded version to an HTML element for 'highlighting' effect - * - * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) - * @return {string} HTML string - */ - this.render = function () { - var html = arguments.length <= 0 || arguments[0] === undefined ? ngModel.$modelValue : arguments[0]; - - html = (html || '').toString(); - _this2.mentions.forEach(function (mention) { - html = html.replace(_this2.encode(mention), _this2.highlight(mention)); - }); - $element.next().html(html); - return html; - }; - - /** - * $mention.highlight() - * - * Returns a choice in HTML highlight formatting - * - * @param {mixed|object} choice The choice to be highlighted - * @return {string} HTML highlighted version of the choice - */ - this.highlight = function (choice) { - return '' + this.label(choice) + ''; - }; - - /** - * $mention.decode() - * - * @note NOT CURRENTLY USED - * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) - * @return {string} plaintext string with encoded labels used - */ - this.decode = function () { - var value = arguments.length <= 0 || arguments[0] === undefined ? ngModel.$modelValue : arguments[0]; - - return value ? value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi, '$1') : ''; - }; - - /** - * $mention.label() - * - * Converts a choice object to a human-readable string - * - * @param {mixed|object} choice The choice to be rendered - * @return {string} Human-readable string version of choice - */ - this.label = function (choice) { - return choice.first + ' ' + choice.last; - }; - - /** - * $mention.encode() - * - * Converts a choice object to a syntax-encoded string - * - * @param {mixed|object} choice The choice to be encoded - * @return {string} Syntax-encoded string version of choice - */ - this.encode = function (choice) { - return '@[' + this.label(choice) + ':' + choice.id + ']'; - }; - - /** - * $mention.replace() - * - * Replaces the trigger-text with the mention label - * - * @param {mixed|object} mention The choice to replace with - * @param {regex.exec()} [search] A regex search result for the trigger-text (default: this.searching) - * @param {string} [text] String to perform the replacement on (default: ngModel.$viewValue) - * @return {string} Human-readable string - */ - this.replace = function (mention) { - var search = arguments.length <= 1 || arguments[1] === undefined ? this.searching : arguments[1]; - var text = arguments.length <= 2 || arguments[2] === undefined ? ngModel.$viewValue : arguments[2]; - - // TODO: come up with a better way to detect what to remove - // TODO: consider alternative to using regex match - text = text.substr(0, search.index + search[0].indexOf('@')) + this.label(mention) + ' ' + text.substr(search.index + search[0].length); - return text; - }; - - /** - * $mention.select() - * - * Adds a choice to this.mentions collection and updates the view - * - * @param {mixed|object} [choice] The selected choice (default: activeChoice) - */ - this.select = function () { - var choice = arguments.length <= 0 || arguments[0] === undefined ? this.activeChoice : arguments[0]; - - // Add the mention - this.mentions.push(choice); - - // Replace the search with the label - ngModel.$setViewValue(this.replace(choice)); - - // Close choices panel - this.cancel(); - - // Update the textarea - ngModel.$render(); - }; - - /** - * $mention.up() - * - * Moves this.activeChoice up the this.choices collection - */ - this.up = function () { - var index = this.choices.indexOf(this.activeChoice); - if (index > 0) { - this.activeChoice = this.choices[index - 1]; - } else { - this.activeChoice = this.choices[this.choices.length - 1]; - } - }; - - /** - * $mention.down() - * - * Moves this.activeChoice down the this.choices collection - */ - this.down = function () { - var index = this.choices.indexOf(this.activeChoice); - if (index < this.choices.length - 1) { - this.activeChoice = this.choices[index + 1]; - } else { - this.activeChoice = this.choices[0]; - } - }; - - /** - * $mention.search() - * - * Searches for a list of mention choices and populates - * $mention.choices and $mention.activeChoice - * - * @param {regex.exec()} match The trigger-text regex match object - * @todo Try to avoid using a regex match object - */ - this.search = function (match) { - var _this3 = this; - - this.searching = match; - - return $q.when(this.findChoices(match, this.mentions)).then(function (choices) { - _this3.choices = choices; - _this3.activeChoice = choices[0]; - return choices; - }); - }; - - /** - * $mention.findChoices() - * - * @param {regex.exec()} match The trigger-text regex match object - * @todo Try to avoid using a regex match object - * @todo Make it easier to override this - * @return {array[choice]|Promise} The list of possible choices - */ - this.findChoices = function (match, mentions) { - return []; - }; - - /** - * $mention.cancel() - * - * Clears the choices dropdown info and stops searching - */ - this.cancel = function () { - this.choices = []; - this.searching = null; - }; - - this.autogrow = function () { - $element[0].style.height = 0; // autoshrink - need accurate scrollHeight - var style = getComputedStyle($element[0]); - if (style.boxSizing == 'border-box') $element[0].style.height = $element[0].scrollHeight + 'px'; - }; - - // Interactions to trigger searching - $element.on('keyup click focus', function (event) { - // If event is fired AFTER activeChoice move is performed - if (_this2.moved) return _this2.moved = false; - // Don't trigger on selection - if ($element[0].selectionStart != $element[0].selectionEnd) return; - var text = $element.val(); - // text to left of cursor ends with `@sometext` - var match = _this2.pattern.exec(text.substr(0, $element[0].selectionStart)); - if (match) { - _this2.search(match); - } else { - _this2.cancel(); - } + } + }; +}); +'use strict'; - $scope.$apply(); +angular.module('ui.mention').controller('uiMentionController', function ($element, $scope, $attrs, $q, $timeout, $document) { + var _this2 = this; + + // Beginning of input or preceeded by spaces: @sometext + this.pattern = this.pattern || /(?:\s+|^)@(\w+(?: \w+)?)$/; + this.$element = $element; + this.choices = []; + this.mentions = []; + var ngModel; + + /** + * $mention.init() + * + * Initializes the plugin by setting up the ngModelController properties + * + * @param {ngModelController} model + */ + this.init = function (model) { + var _this = this; + + // Leading whitespace shows up in the textarea but not the preview + $attrs.ngTrim = 'false'; + + ngModel = model; + + ngModel.$parsers.push(function (value) { + // Removes any mentions that aren't used + _this.mentions = _this.mentions.filter(function (mention) { + if (~value.indexOf(_this.label(mention))) return value = value.replace(_this.label(mention), _this.encode(mention)); }); - $element.on('keydown', function (event) { - if (!_this2.searching) return; - - switch (event.keyCode) { - case 13: - // return - _this2.select(); - break; - case 38: - // up - _this2.up(); - break; - case 40: - // down - _this2.down(); - break; - default: - // Exit function - return; - } + _this.render(value); + + return value; + }); - _this2.moved = true; - event.preventDefault(); + ngModel.$formatters.push(function () { + var value = arguments.length <= 0 || arguments[0] === undefined ? '' : arguments[0]; - $scope.$apply(); + // In case the value is a different primitive + value = value.toString(); + + // Removes any mentions that aren't used + _this.mentions = _this.mentions.filter(function (mention) { + if (~value.indexOf(_this.encode(mention))) { + value = value.replace(_this.encode(mention), _this.label(mention)); + return true; + } else { + return false; + } }); - this.onMouseup = (function (event) { - var _this4 = this; + return value; + }); - if (event.target == $element[0]) return; + ngModel.$render = function () { + $element.val(ngModel.$viewValue || ''); + _this.render(); + }; + }; - $document.off('mouseup', this.onMouseup); + /** + * $mention.render() + * + * Renders the syntax-encoded version to an HTML element for 'highlighting' effect + * + * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) + * @return {string} HTML string + */ + this.render = function () { + var html = arguments.length <= 0 || arguments[0] === undefined ? ngModel.$modelValue : arguments[0]; + + html = (html || '').toString(); + _this2.mentions.forEach(function (mention) { + html = html.replace(_this2.encode(mention), _this2.highlight(mention)); + }); + $element.next().html(html); + return html; + }; - if (!this.searching) return; + /** + * $mention.highlight() + * + * Returns a choice in HTML highlight formatting + * + * @param {mixed|object} choice The choice to be highlighted + * @return {string} HTML highlighted version of the choice + */ + this.highlight = function (choice) { + return '' + this.label(choice) + ''; + }; - // Let ngClick fire first - $scope.$evalAsync(function () { - _this4.cancel(); - }); - }).bind(this); + /** + * $mention.decode() + * + * @note NOT CURRENTLY USED + * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) + * @return {string} plaintext string with encoded labels used + */ + this.decode = function () { + var value = arguments.length <= 0 || arguments[0] === undefined ? ngModel.$modelValue : arguments[0]; + + return value ? value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi, '$1') : ''; + }; - $element.on('focus', function (event) { - $document.on('mouseup', _this2.onMouseup); - }); + /** + * $mention.label() + * + * Converts a choice object to a human-readable string + * + * @param {mixed|object} choice The choice to be rendered + * @return {string} Human-readable string version of choice + */ + this.label = function (choice) { + return choice.first + ' ' + choice.last; + }; + + /** + * $mention.encode() + * + * Converts a choice object to a syntax-encoded string + * + * @param {mixed|object} choice The choice to be encoded + * @return {string} Syntax-encoded string version of choice + */ + this.encode = function (choice) { + return '@[' + this.label(choice) + ':' + choice.id + ']'; + }; + + /** + * $mention.replace() + * + * Replaces the trigger-text with the mention label + * + * @param {mixed|object} mention The choice to replace with + * @param {regex.exec()} [search] A regex search result for the trigger-text (default: this.searching) + * @param {string} [text] String to perform the replacement on (default: ngModel.$viewValue) + * @return {string} Human-readable string + */ + this.replace = function (mention) { + var search = arguments.length <= 1 || arguments[1] === undefined ? this.searching : arguments[1]; + var text = arguments.length <= 2 || arguments[2] === undefined ? ngModel.$viewValue : arguments[2]; + + // TODO: come up with a better way to detect what to remove + // TODO: consider alternative to using regex match + text = text.substr(0, search.index + search[0].indexOf('@')) + this.label(mention) + ' ' + text.substr(search.index + search[0].length); + return text; + }; + + /** + * $mention.select() + * + * Adds a choice to this.mentions collection and updates the view + * + * @param {mixed|object} [choice] The selected choice (default: activeChoice) + */ + this.select = function () { + var choice = arguments.length <= 0 || arguments[0] === undefined ? this.activeChoice : arguments[0]; + + // Add the mention + this.mentions.push(choice); + + // Replace the search with the label + ngModel.$setViewValue(this.replace(choice)); - // Autogrow is mandatory beacuse the textarea scrolls away from highlights - $element.on('input', this.autogrow); - // Initialize autogrow height - $timeout(this.autogrow, true); + // Close choices panel + this.cancel(); + + // Update the textarea + ngModel.$render(); + }; + + /** + * $mention.up() + * + * Moves this.activeChoice up the this.choices collection + */ + this.up = function () { + var index = this.choices.indexOf(this.activeChoice); + if (index > 0) { + this.activeChoice = this.choices[index - 1]; + } else { + this.activeChoice = this.choices[this.choices.length - 1]; } }; + + /** + * $mention.down() + * + * Moves this.activeChoice down the this.choices collection + */ + this.down = function () { + var index = this.choices.indexOf(this.activeChoice); + if (index < this.choices.length - 1) { + this.activeChoice = this.choices[index + 1]; + } else { + this.activeChoice = this.choices[0]; + } + }; + + /** + * $mention.search() + * + * Searches for a list of mention choices and populates + * $mention.choices and $mention.activeChoice + * + * @param {regex.exec()} match The trigger-text regex match object + * @todo Try to avoid using a regex match object + */ + this.search = function (match) { + var _this3 = this; + + this.searching = match; + + return $q.when(this.findChoices(match, this.mentions)).then(function (choices) { + _this3.choices = choices; + _this3.activeChoice = choices[0]; + return choices; + }); + }; + + /** + * $mention.findChoices() + * + * @param {regex.exec()} match The trigger-text regex match object + * @todo Try to avoid using a regex match object + * @todo Make it easier to override this + * @return {array[choice]|Promise} The list of possible choices + */ + this.findChoices = function (match, mentions) { + return []; + }; + + /** + * $mention.cancel() + * + * Clears the choices dropdown info and stops searching + */ + this.cancel = function () { + this.choices = []; + this.searching = null; + }; + + this.autogrow = function () { + $element[0].style.height = 0; // autoshrink - need accurate scrollHeight + var style = getComputedStyle($element[0]); + if (style.boxSizing == 'border-box') $element[0].style.height = $element[0].scrollHeight + 'px'; + }; + + // Interactions to trigger searching + $element.on('keyup click focus', function (event) { + // If event is fired AFTER activeChoice move is performed + if (_this2.moved) return _this2.moved = false; + // Don't trigger on selection + if ($element[0].selectionStart != $element[0].selectionEnd) return; + var text = $element.val(); + // text to left of cursor ends with `@sometext` + var match = _this2.pattern.exec(text.substr(0, $element[0].selectionStart)); + if (match) { + _this2.search(match); + } else { + _this2.cancel(); + } + + $scope.$apply(); + }); + + $element.on('keydown', function (event) { + if (!_this2.searching) return; + + switch (event.keyCode) { + case 13: + // return + _this2.select(); + break; + case 38: + // up + _this2.up(); + break; + case 40: + // down + _this2.down(); + break; + default: + // Exit function + return; + } + + _this2.moved = true; + event.preventDefault(); + + $scope.$apply(); + }); + + this.onMouseup = (function (event) { + var _this4 = this; + + if (event.target == $element[0]) return; + + $document.off('mouseup', this.onMouseup); + + if (!this.searching) return; + + // Let ngClick fire first + $scope.$evalAsync(function () { + _this4.cancel(); + }); + }).bind(this); + + $element.on('focus', function (event) { + $document.on('mouseup', _this2.onMouseup); + }); + + // Autogrow is mandatory beacuse the textarea scrolls away from highlights + $element.on('input', this.autogrow); + // Initialize autogrow height + $timeout(this.autogrow, true); }); \ No newline at end of file diff --git a/dist/mention.min.js b/dist/mention.min.js index 8cd634f..f4d9bdb 100644 --- a/dist/mention.min.js +++ b/dist/mention.min.js @@ -1 +1 @@ -"use strict";var _slicedToArray=function(){function sliceIterator(arr,i){var _arr=[],_n=!0,_d=!1,_e=void 0;try{for(var _s,_i=arr[Symbol.iterator]();!(_n=(_s=_i.next()).done)&&(_arr.push(_s.value),!i||_arr.length!==i);_n=!0);}catch(err){_d=!0,_e=err}finally{try{!_n&&_i["return"]&&_i["return"]()}finally{if(_d)throw _e}}return _arr}return function(arr,i){if(Array.isArray(arr))return arr;if(Symbol.iterator in Object(arr))return sliceIterator(arr,i);throw new TypeError("Invalid attempt to destructure non-iterable instance")}}();angular.module("ui.mention",[]).directive("uiMention",function($q,$timeout,$document){return{require:["ngModel","uiMention"],controllerAs:"$mention",link:function($scope,$element,$attrs,_ref){var _ref2=_slicedToArray(_ref,2),ngModel=_ref2[0],uiMention=_ref2[1];uiMention.init(ngModel)},controller:function($element,$scope,$attrs){var _this2=this;this.pattern=this.pattern||/(?:\s+|^)@(\w+(?: \w+)?)$/,this.$element=$element,this.choices=[],this.mentions=[];var ngModel;this.init=function(model){var _this=this;$attrs.ngTrim="false",ngModel=model,ngModel.$parsers.push(function(value){return _this.mentions=_this.mentions.filter(function(mention){return~value.indexOf(_this.label(mention))?value=value.replace(_this.label(mention),_this.encode(mention)):void 0}),_this.render(value),value}),ngModel.$formatters.push(function(){var value=arguments.length<=0||void 0===arguments[0]?"":arguments[0];return value=value.toString(),_this.mentions=_this.mentions.filter(function(mention){return~value.indexOf(_this.encode(mention))?(value=value.replace(_this.encode(mention),_this.label(mention)),!0):!1}),value}),ngModel.$render=function(){$element.val(ngModel.$viewValue||""),_this.render()}},this.render=function(){var html=arguments.length<=0||void 0===arguments[0]?ngModel.$modelValue:arguments[0];return html=(html||"").toString(),_this2.mentions.forEach(function(mention){html=html.replace(_this2.encode(mention),_this2.highlight(mention))}),$element.next().html(html),html},this.highlight=function(choice){return""+this.label(choice)+""},this.decode=function(){var value=arguments.length<=0||void 0===arguments[0]?ngModel.$modelValue:arguments[0];return value?value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi,"$1"):""},this.label=function(choice){return choice.first+" "+choice.last},this.encode=function(choice){return"@["+this.label(choice)+":"+choice.id+"]"},this.replace=function(mention){var search=arguments.length<=1||void 0===arguments[1]?this.searching:arguments[1],text=arguments.length<=2||void 0===arguments[2]?ngModel.$viewValue:arguments[2];return text=text.substr(0,search.index+search[0].indexOf("@"))+this.label(mention)+" "+text.substr(search.index+search[0].length)},this.select=function(){var choice=arguments.length<=0||void 0===arguments[0]?this.activeChoice:arguments[0];this.mentions.push(choice),ngModel.$setViewValue(this.replace(choice)),this.cancel(),ngModel.$render()},this.up=function(){var index=this.choices.indexOf(this.activeChoice);this.activeChoice=index>0?this.choices[index-1]:this.choices[this.choices.length-1]},this.down=function(){var index=this.choices.indexOf(this.activeChoice);this.activeChoice=index"+this.label(choice)+""},this.decode=function(){var value=arguments.length<=0||void 0===arguments[0]?ngModel.$modelValue:arguments[0];return value?value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi,"$1"):""},this.label=function(choice){return choice.first+" "+choice.last},this.encode=function(choice){return"@["+this.label(choice)+":"+choice.id+"]"},this.replace=function(mention){var search=arguments.length<=1||void 0===arguments[1]?this.searching:arguments[1],text=arguments.length<=2||void 0===arguments[2]?ngModel.$viewValue:arguments[2];return text=text.substr(0,search.index+search[0].indexOf("@"))+this.label(mention)+" "+text.substr(search.index+search[0].length)},this.select=function(){var choice=arguments.length<=0||void 0===arguments[0]?this.activeChoice:arguments[0];this.mentions.push(choice),ngModel.$setViewValue(this.replace(choice)),this.cancel(),ngModel.$render()},this.up=function(){var index=this.choices.indexOf(this.activeChoice);this.activeChoice=index>0?this.choices[index-1]:this.choices[this.choices.length-1]},this.down=function(){var index=this.choices.indexOf(this.activeChoice);this.activeChoice=index { - // Removes any mentions that aren't used - this.mentions = this.mentions.filter( mention => { - if (~value.indexOf(this.label(mention))) - return value = value.replace(this.label(mention), this.encode(mention)); - }); - - this.render(value); - - return value; - }); - - ngModel.$formatters.push( (value = '') => { - // In case the value is a different primitive - value = value.toString(); - - // Removes any mentions that aren't used - this.mentions = this.mentions.filter( mention => { - if (~value.indexOf(this.encode(mention))) { - value = value.replace(this.encode(mention), this.label(mention)); - return true; - } else { - return false; - } - }); - - return value; - }); - - ngModel.$render = () => { - $element.val(ngModel.$viewValue || ''); - this.render(); - }; - }; - - /** - * $mention.render() - * - * Renders the syntax-encoded version to an HTML element for 'highlighting' effect - * - * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) - * @return {string} HTML string - */ - this.render = (html = ngModel.$modelValue) => { - html = (html || '').toString(); - this.mentions.forEach( mention => { - html = html.replace(this.encode(mention), this.highlight(mention)); - }); - $element.next().html(html); - return html; - }; - - /** - * $mention.highlight() - * - * Returns a choice in HTML highlight formatting - * - * @param {mixed|object} choice The choice to be highlighted - * @return {string} HTML highlighted version of the choice - */ - this.highlight = function(choice) { - return `${this.label(choice)}`; - }; - - /** - * $mention.decode() - * - * @note NOT CURRENTLY USED - * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) - * @return {string} plaintext string with encoded labels used - */ - this.decode = function(value = ngModel.$modelValue) { - return value ? value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi, '$1') : ''; - }; - - /** - * $mention.label() - * - * Converts a choice object to a human-readable string - * - * @param {mixed|object} choice The choice to be rendered - * @return {string} Human-readable string version of choice - */ - this.label = function(choice) { - return `${choice.first} ${choice.last}`; - }; - - /** - * $mention.encode() - * - * Converts a choice object to a syntax-encoded string - * - * @param {mixed|object} choice The choice to be encoded - * @return {string} Syntax-encoded string version of choice - */ - this.encode = function(choice) { - return `@[${this.label(choice)}:${choice.id}]`; - }; - - /** - * $mention.replace() - * - * Replaces the trigger-text with the mention label - * - * @param {mixed|object} mention The choice to replace with - * @param {regex.exec()} [search] A regex search result for the trigger-text (default: this.searching) - * @param {string} [text] String to perform the replacement on (default: ngModel.$viewValue) - * @return {string} Human-readable string - */ - this.replace = function(mention, search = this.searching, text = ngModel.$viewValue) { - // TODO: come up with a better way to detect what to remove - // TODO: consider alternative to using regex match - text = text.substr(0, search.index + search[0].indexOf('@')) + - this.label(mention) + ' ' + - text.substr(search.index + search[0].length); - return text; - }; - - /** - * $mention.select() - * - * Adds a choice to this.mentions collection and updates the view - * - * @param {mixed|object} [choice] The selected choice (default: activeChoice) - */ - this.select = function(choice = this.activeChoice) { - // Add the mention - this.mentions.push(choice); - - // Replace the search with the label - ngModel.$setViewValue(this.replace(choice)); - - // Close choices panel - this.cancel(); - - // Update the textarea - ngModel.$render(); - }; - - /** - * $mention.up() - * - * Moves this.activeChoice up the this.choices collection - */ - this.up = function() { - let index = this.choices.indexOf(this.activeChoice); - if (index > 0) { - this.activeChoice = this.choices[index - 1]; - } else { - this.activeChoice = this.choices[this.choices.length - 1]; - } - }; - - /** - * $mention.down() - * - * Moves this.activeChoice down the this.choices collection - */ - this.down = function() { - let index = this.choices.indexOf(this.activeChoice); - if (index < this.choices.length - 1) { - this.activeChoice = this.choices[index + 1]; - } else { - this.activeChoice = this.choices[0]; - } - }; - - /** - * $mention.search() - * - * Searches for a list of mention choices and populates - * $mention.choices and $mention.activeChoice - * - * @param {regex.exec()} match The trigger-text regex match object - * @todo Try to avoid using a regex match object - */ - this.search = function(match) { - this.searching = match; - - return $q.when( this.findChoices(match, this.mentions) ) - .then( choices => { - this.choices = choices; - this.activeChoice = choices[0]; - return choices; - }); - }; - - /** - * $mention.findChoices() - * - * @param {regex.exec()} match The trigger-text regex match object - * @todo Try to avoid using a regex match object - * @todo Make it easier to override this - * @return {array[choice]|Promise} The list of possible choices - */ - this.findChoices = function(match, mentions) { - return []; - }; - - /** - * $mention.cancel() - * - * Clears the choices dropdown info and stops searching - */ - this.cancel = function() { - this.choices = []; - this.searching = null; - }; - - this.autogrow = function() { - $element[0].style.height = 0; // autoshrink - need accurate scrollHeight - let style = getComputedStyle($element[0]); - if (style.boxSizing == 'border-box') - $element[0].style.height = $element[0].scrollHeight + 'px'; - }; - - // Interactions to trigger searching - $element.on('keyup click focus', event => { - // If event is fired AFTER activeChoice move is performed - if (this.moved) - return this.moved = false; - // Don't trigger on selection - if ($element[0].selectionStart != $element[0].selectionEnd) - return; - let text = $element.val(); - // text to left of cursor ends with `@sometext` - let match = this.pattern.exec(text.substr(0, $element[0].selectionStart)); - if (match) { - this.search(match); - } else { - this.cancel(); - } - - $scope.$apply(); - }); - - $element.on('keydown', event => { - if (!this.searching) - return; - - switch (event.keyCode) { - case 13: // return - this.select(); - break; - case 38: // up - this.up(); - break; - case 40: // down - this.down(); - break; - default: - // Exit function - return; - } - - this.moved = true; - event.preventDefault(); - - $scope.$apply(); - }); - - - - this.onMouseup = (function(event) { - if (event.target == $element[0]) - return - - $document.off('mouseup', this.onMouseup); - - if (!this.searching) - return; - - // Let ngClick fire first - $scope.$evalAsync( () => { - this.cancel(); - }); - }).bind(this); - - $element.on('focus', event => { - $document.on('mouseup', this.onMouseup); - }); - - // Autogrow is mandatory beacuse the textarea scrolls away from highlights - $element.on('input', this.autogrow); - // Initialize autogrow height - $timeout(this.autogrow, true); } }; }); diff --git a/src/mentionController.es6.js b/src/mentionController.es6.js new file mode 100644 index 0000000..2c1450f --- /dev/null +++ b/src/mentionController.es6.js @@ -0,0 +1,312 @@ +angular.module('ui.mention') +.controller('uiMentionController', function ( + $element, $scope, $attrs, $q, $timeout, $document + ) { + + // Beginning of input or preceeded by spaces: @sometext + this.pattern = this.pattern || /(?:\s+|^)@(\w+(?: \w+)?)$/; + this.$element = $element; + this.choices = []; + this.mentions = []; + var ngModel; + + /** + * $mention.init() + * + * Initializes the plugin by setting up the ngModelController properties + * + * @param {ngModelController} model + */ + this.init = function(model) { + // Leading whitespace shows up in the textarea but not the preview + $attrs.ngTrim = 'false'; + + ngModel = model; + + ngModel.$parsers.push( value => { + // Removes any mentions that aren't used + this.mentions = this.mentions.filter( mention => { + if (~value.indexOf(this.label(mention))) + return value = value.replace(this.label(mention), this.encode(mention)); + }); + + this.render(value); + + return value; + }); + + ngModel.$formatters.push( (value = '') => { + // In case the value is a different primitive + value = value.toString(); + + // Removes any mentions that aren't used + this.mentions = this.mentions.filter( mention => { + if (~value.indexOf(this.encode(mention))) { + value = value.replace(this.encode(mention), this.label(mention)); + return true; + } else { + return false; + } + }); + + return value; + }); + + ngModel.$render = () => { + $element.val(ngModel.$viewValue || ''); + this.render(); + }; + }; + + /** + * $mention.render() + * + * Renders the syntax-encoded version to an HTML element for 'highlighting' effect + * + * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) + * @return {string} HTML string + */ + this.render = (html = ngModel.$modelValue) => { + html = (html || '').toString(); + this.mentions.forEach( mention => { + html = html.replace(this.encode(mention), this.highlight(mention)); + }); + $element.next().html(html); + return html; + }; + + /** + * $mention.highlight() + * + * Returns a choice in HTML highlight formatting + * + * @param {mixed|object} choice The choice to be highlighted + * @return {string} HTML highlighted version of the choice + */ + this.highlight = function(choice) { + return `${this.label(choice)}`; + }; + + /** + * $mention.decode() + * + * @note NOT CURRENTLY USED + * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) + * @return {string} plaintext string with encoded labels used + */ + this.decode = function(value = ngModel.$modelValue) { + return value ? value.replace(/@\[([\s\w]+):[0-9a-z-]+\]/gi, '$1') : ''; + }; + + /** + * $mention.label() + * + * Converts a choice object to a human-readable string + * + * @param {mixed|object} choice The choice to be rendered + * @return {string} Human-readable string version of choice + */ + this.label = function(choice) { + return `${choice.first} ${choice.last}`; + }; + + /** + * $mention.encode() + * + * Converts a choice object to a syntax-encoded string + * + * @param {mixed|object} choice The choice to be encoded + * @return {string} Syntax-encoded string version of choice + */ + this.encode = function(choice) { + return `@[${this.label(choice)}:${choice.id}]`; + }; + + /** + * $mention.replace() + * + * Replaces the trigger-text with the mention label + * + * @param {mixed|object} mention The choice to replace with + * @param {regex.exec()} [search] A regex search result for the trigger-text (default: this.searching) + * @param {string} [text] String to perform the replacement on (default: ngModel.$viewValue) + * @return {string} Human-readable string + */ + this.replace = function(mention, search = this.searching, text = ngModel.$viewValue) { + // TODO: come up with a better way to detect what to remove + // TODO: consider alternative to using regex match + text = text.substr(0, search.index + search[0].indexOf('@')) + + this.label(mention) + ' ' + + text.substr(search.index + search[0].length); + return text; + }; + + /** + * $mention.select() + * + * Adds a choice to this.mentions collection and updates the view + * + * @param {mixed|object} [choice] The selected choice (default: activeChoice) + */ + this.select = function(choice = this.activeChoice) { + // Add the mention + this.mentions.push(choice); + + // Replace the search with the label + ngModel.$setViewValue(this.replace(choice)); + + // Close choices panel + this.cancel(); + + // Update the textarea + ngModel.$render(); + }; + + /** + * $mention.up() + * + * Moves this.activeChoice up the this.choices collection + */ + this.up = function() { + let index = this.choices.indexOf(this.activeChoice); + if (index > 0) { + this.activeChoice = this.choices[index - 1]; + } else { + this.activeChoice = this.choices[this.choices.length - 1]; + } + }; + + /** + * $mention.down() + * + * Moves this.activeChoice down the this.choices collection + */ + this.down = function() { + let index = this.choices.indexOf(this.activeChoice); + if (index < this.choices.length - 1) { + this.activeChoice = this.choices[index + 1]; + } else { + this.activeChoice = this.choices[0]; + } + }; + + /** + * $mention.search() + * + * Searches for a list of mention choices and populates + * $mention.choices and $mention.activeChoice + * + * @param {regex.exec()} match The trigger-text regex match object + * @todo Try to avoid using a regex match object + */ + this.search = function(match) { + this.searching = match; + + return $q.when( this.findChoices(match, this.mentions) ) + .then( choices => { + this.choices = choices; + this.activeChoice = choices[0]; + return choices; + }); + }; + + /** + * $mention.findChoices() + * + * @param {regex.exec()} match The trigger-text regex match object + * @todo Try to avoid using a regex match object + * @todo Make it easier to override this + * @return {array[choice]|Promise} The list of possible choices + */ + this.findChoices = function(match, mentions) { + return []; + }; + + /** + * $mention.cancel() + * + * Clears the choices dropdown info and stops searching + */ + this.cancel = function() { + this.choices = []; + this.searching = null; + }; + + this.autogrow = function() { + $element[0].style.height = 0; // autoshrink - need accurate scrollHeight + let style = getComputedStyle($element[0]); + if (style.boxSizing == 'border-box') + $element[0].style.height = $element[0].scrollHeight + 'px'; + }; + + // Interactions to trigger searching + $element.on('keyup click focus', event => { + // If event is fired AFTER activeChoice move is performed + if (this.moved) + return this.moved = false; + // Don't trigger on selection + if ($element[0].selectionStart != $element[0].selectionEnd) + return; + let text = $element.val(); + // text to left of cursor ends with `@sometext` + let match = this.pattern.exec(text.substr(0, $element[0].selectionStart)); + if (match) { + this.search(match); + } else { + this.cancel(); + } + + $scope.$apply(); + }); + + $element.on('keydown', event => { + if (!this.searching) + return; + + switch (event.keyCode) { + case 13: // return + this.select(); + break; + case 38: // up + this.up(); + break; + case 40: // down + this.down(); + break; + default: + // Exit function + return; + } + + this.moved = true; + event.preventDefault(); + + $scope.$apply(); + }); + + + + this.onMouseup = (function(event) { + if (event.target == $element[0]) + return + + $document.off('mouseup', this.onMouseup); + + if (!this.searching) + return; + + // Let ngClick fire first + $scope.$evalAsync( () => { + this.cancel(); + }); + }).bind(this); + + $element.on('focus', event => { + $document.on('mouseup', this.onMouseup); + }); + + // Autogrow is mandatory beacuse the textarea scrolls away from highlights + $element.on('input', this.autogrow); + // Initialize autogrow height + $timeout(this.autogrow, true); +}); diff --git a/test/uiMentionController.spec.js b/test/uiMentionController.spec.js new file mode 100644 index 0000000..eeb3885 --- /dev/null +++ b/test/uiMentionController.spec.js @@ -0,0 +1,512 @@ +describe('uiMentionController', () => { + let $scope, $attrs, $q, $timeout, $document, createController, ngModelController; + + beforeEach(() => { + module('ui.mention'); + + inject(($injector, $controller) => { + $scope = $injector.get('$rootScope').$new(); + $q = $injector.get('$q'); + $timeout = $injector.get('$timeout'); + $document = $injector.get('$document'); + $attrs = {}; + + createController = (el) => { + return $controller('uiMentionController', { + $scope: $scope, + $attrs: $attrs, + $element: el, + $q: $q, + $timeout: $timeout, + $document: $document + }); + }; + }); + }); + + context('on invocation', () => { + let ctrlInstance, $element; + + beforeEach(() => { + $element = angular.element(''); + ctrlInstance = createController($element); + }); + + it('exposes a pattern', () => { + expect(ctrlInstance.pattern).to.eql(/(?:\s+|^)@(\w+(?: \w+)?)$/); + }); + + it('exposes the given $element', () => { + expect(ctrlInstance.$element).to.eq($element); + }); + + it('exposes an array of choices', () => { + expect(ctrlInstance.choices).to.eql([]); + }); + + it('exposes an array of mentions', () => { + expect(ctrlInstance.mentions).to.eql([]); + }); + }); + + context('public API', function () { + let ctrlInstance, $element; + + beforeEach(function () { + $scope.model = 'bar'; + $element = angular.element(''); + ctrlInstance = createController($element); + $scope.$digest(); + }); + + [ + 'init', 'render', 'highlight', 'decode', 'label', 'encode', 'replace', + 'select', 'up', 'down', 'search', 'findChoices', 'cancel', 'autogrow' + ].forEach((fn) => { + it(fn + ' is a public API method on ' + ctrlInstance, () => { + expect(ctrlInstance).to.have.property(fn).that.is.a('function'); + }); + }); + + context('.init()', () => { + let ngModel, mentions; + + beforeEach(() => { + ngModel = { $parsers: [], $formatters: [] }; + mentions = [{ id: 1, first: 'foo', last: 'bar' }, { id: 2, first: 'k', last: 'v' }]; + ctrlInstance.init(ngModel); + ctrlInstance.mentions = mentions; + }); + + it('sets $attrs.ngTrim to false', () => { + expect($attrs.ngTrim).to.eq('false'); + }); + + context('ngModel.$parsers', () => { + let $parsers, mentionParser; + + beforeEach(() => { + $parsers = ngModel.$parsers; + mentionParser = $parsers[0]; + }); + + it('received a new $parser', () => { + expect($parsers.length).to.eq(1); + }); + + it('sets up a mentions property on the controller instance', () => { + mentionParser(''); + expect(ctrlInstance).to.have.property('mentions').that.is.an('array'); + }); + + it('returns the given value', () => { + expect(mentionParser('foo')).to.eq('foo'); + }); + + it('filters out non matching mentions', () => { + mentionParser('foo bar'); + expect(ctrlInstance.mentions).to.eql(mentions.slice(0,1)); + }); + + it('calls the controller render method with the given value', () => { + let spy = sinon.spy(ctrlInstance, 'render'); + mentionParser('foo bar'); + expect(spy).to.have.been.calledOnce.and.calledWith('@[foo bar:1]'); + }); + }); + + context('ngModel.$formatters', () => { + let $formatters, formatter; + + beforeEach(() => { + $formatters = ngModel.$formatters; + formatter = $formatters[0]; + }); + + it('received a new $formatter', () => { + expect($formatters.length).to.eq(1); + }); + + it('returns an empty string by default', () => { + expect(formatter()).to.eq(''); + }); + + it('casts any non-string argument to a string', () => { + expect(formatter(123)).to.eq('123'); + expect(formatter(false)).to.eq('false'); + expect(formatter(true)).to.eq('true'); + expect(formatter({})).to.eq('[object Object]'); + expect(formatter([])).to.eq(''); + }); + + it('filters out non matching mentions', () => { + expect(ctrlInstance.mentions).to.include(mentions[1]); + formatter('@[foo bar:1]'); + expect(ctrlInstance.mentions).to.not.include(mentions[1]); + }); + + it('returns an encoded version of the passed value', () => { + expect(formatter('@[foo bar:1]')).to.eq('foo bar'); + }); + }); + + context('ngModel.$render', () => { + it('sets the val property of $element to ngModel.$viewValue', () => { + ngModel.$viewValue = 'wat'; + ngModel.$render(); + expect($element.val()).to.eq('wat'); + }); + + it('defaults to an empty string', () => { + ngModel.$render(); + expect($element.val()).to.eq(''); + }); + + it('calls the controller render method', () => { + let spy = sinon.spy(ctrlInstance, 'render'); + ngModel.$render(); + expect(spy).to.have.been.calledOnce; + }); + }); + }); + + context('.render()', () => { + let ngModel, mentions; + + beforeEach(() => { + ngModel = { $parsers: [], $formatters: [] }; + mentions = [{ id: 1, first: 'foo', last: 'bar' }, { id: 2, first: 'k', last: 'v' }]; + + $element = angular.element(''); + ctrlInstance = createController($element); + $scope.$digest(); + + ctrlInstance.init(ngModel); + ctrlInstance.mentions = mentions; + }); + + it('the default argument is ngModel.$modelValue if no other was passed', () => { + ngModel.$modelValue = 'nope'; + expect(ctrlInstance.render()).to.eq('nope'); + }); + + it('casts the given argument to a string', () => { + expect(ctrlInstance.render(123)).to.eq('123'); + }); + + it('converts a syntax encoded string to HTML', () => { + ngModel.$modelValue = '@[foo bar:1] @[k v:2]'; + expect(ctrlInstance.render()).to.eq('foo bar k v'); + }); + + it('does not convert non-mentions', () => { + ngModel.$modelValue = '@[wat nope:123]'; + expect(ctrlInstance.render()).to.not.eq('wat nope'); + }); + + it('replaces the html of $element.next with the converted value', () => { + ngModel.$modelValue = '@[foo bar:1] @[k v:2]'; + ctrlInstance.render(); + expect($element.next().html()).to.eq('foo bar k v') + }); + }); + + context('.highlight()', () => { + let choice; + + beforeEach(() => { + choice = { first: 'x', last: 'y' }; + }); + + it('returns an HTML formatted version of the given argument', () => { + expect(ctrlInstance.highlight(choice)).to.eq('x y'); + }); + + it('calls the controller label method internally', () => { + let spy = sinon.spy(ctrlInstance, 'label'); + ctrlInstance.highlight(choice); + expect(spy).to.have.been.calledOnce.and.calledWith(choice); + }); + }); + + context.skip('.decode()', () => { + /** Untested - NOT CURRENTLY USED **/ + }); + + context('.label()', () => { + it('convets the given object to a readable string', () => { + expect(ctrlInstance.label({ first: 0, last: 1 })).to.eq('0 1'); + }); + }); + + context('.encode()', () => { + it('encodes the given object to a syntax encoded string', () => { + let choice = { first: 'x', last: 'y', id: 123 }; + expect(ctrlInstance.encode(choice)).to.eq('@[x y:123]'); + }); + }); + + context.skip('.replace()', () => { + /** Untested - marked with @TODO's **/ + }); + + context('.select()', () => { + let ngModel, mentions; + + beforeEach(() => { + ngModel = { $parsers: [], $formatters: [], $setViewValue: sinon.stub() }; + ctrlInstance.init(ngModel); + ctrlInstance.searching = ['']; + ngModel.$viewValue = 'foo'; + }); + + it('adds a mention to the current mentions', () => { + expect(ctrlInstance.mentions.length).to.eq(0); + ctrlInstance.select({ first: 'foo', last: 'bar' }); + expect(ctrlInstance.mentions[0]).to.eql({ first: 'foo', last: 'bar' }); + }); + + it('calls the controller cancel method internally', () => { + let spy = sinon.spy(ctrlInstance, 'cancel'); + ctrlInstance.select({ first: 'foo', last: 'bar' }); + expect(spy).to.have.been.calledOnce; + }); + + it('calls the ngModel.$render method internally', () => { + let spy = sinon.spy(ngModel, '$render'); + ctrlInstance.select({ first: 'foo', last: 'bar' }); + expect(spy).to.have.been.calledOnce; + }); + + it('returns nothing', () => { + expect(ctrlInstance.select({ first: 'foo', last: 'bar' })).to.eq(undefined); + }); + }); + + context('.up()', () => { + it('moves the activeChoice up in the choices collection', () => { + let choices = [{ id: 1 }, { id: 2 }, { id: 3 }]; + ctrlInstance.choices = choices; + ctrlInstance.activeChoice = choices[1]; + + ctrlInstance.up(); + expect(ctrlInstance.activeChoice).to.eq(choices[0]); + + ctrlInstance.up(); + expect(ctrlInstance.activeChoice).to.eq(choices[2]); + + ctrlInstance.up(); + expect(ctrlInstance.activeChoice).to.eq(choices[1]); + }); + }); + + context('.down()', () => { + it('moves the activeChoice down in the choices collection', () => { + let choices = [{ id: 1 }, { id: 2 }, { id: 3 }]; + ctrlInstance.choices = choices; + ctrlInstance.activeChoice = choices[1]; + + ctrlInstance.down(); + expect(ctrlInstance.activeChoice).to.eq(choices[2]); + + ctrlInstance.down(); + expect(ctrlInstance.activeChoice).to.eq(choices[0]); + + ctrlInstance.down(); + expect(ctrlInstance.activeChoice).to.eq(choices[1]); + }); + }); + + context('.search()', () => { + it('sets the controller searching property to the passed argument', () => { + ctrlInstance.search('foo'); + expect(ctrlInstance.searching).to.eq('foo'); + }); + + it('returns a promise', () => { + expect(ctrlInstance.search('')).to.have.property('$$state'); + }); + + it('resolves with the possible choices', () => { + function fn () { + return ctrlInstance.search(''); + } + + fn().then(function (res) { + expect(res).to.be.an('array'); + }); + + $timeout.flush(); + }); + }); + + context('.findChoices', () => { + it('returns an array', () => { + expect(ctrlInstance.findChoices()).to.be.an('array'); + }); + }); + + context('.cancel()', () => { + it('clears the controller choices', () => { + ctrlInstance.choices = [{}, {}]; + ctrlInstance.cancel(); + expect(ctrlInstance.choices).to.eql([]); + }); + + it('sets the searching regex to null', () => { + ctrlInstance.searching = /x/.exec('y'); + ctrlInstance.cancel(); + expect(ctrlInstance.searching).to.eq(null); + }); + }); + + context('.autogrow()', () => { + it('sets the $element height to 0', () => { + ctrlInstance.autogrow(); + expect($element[0].style.height).to.eq('0px'); + }); + + it('sets the $element height to scrollHeight if box-sizing is borderBox', () => { + $element[0].style.boxSizing = 'border-box'; + ctrlInstance.autogrow(); + expect($element[0].style.height).to.eq($element[0].scrollHeight + 'px'); + }); + }); + }); + + context('DOM listeners', () => { + let ctrlInstance, $element; + + beforeEach(function () { + $scope.model = 'bar'; + $element = angular.element(''); + ctrlInstance = createController($element); + $scope.$digest(); + }); + + ['keyup', 'click', 'focus'].forEach((ev) => { + context('on ' + ev, () => { + it('sets moved to false if moved is truthy', () => { + ctrlInstance.moved = true; + trigger($element, ev); + expect(ctrlInstance.moved).to.eq(false); + }); + + it('does nothing if the selectionStart does not match selectionEnd', () => { + let spy = sinon.spy($scope, '$apply'); + $element[0].selectionStart = 0; + $element[0].selectionEnd = 1; + + trigger($element, ev); + + expect(spy).to.not.have.been.calledOnce; + }); + + it('searches if there is a match', () => { + let spy = sinon.spy(ctrlInstance, 'search'); + ctrlInstance.pattern = /foo/; + $element.val('@foo'); + $element[0].selectionStart = $element[0].selectionEnd = 4; + + trigger($element, ev); + + expect(spy).to.have.been.calledOnce; + }); + + it('cancels if there is no match', () => { + let spy = sinon.spy(ctrlInstance, 'cancel'); + ctrlInstance.pattern = /foo/; + $element.val('@bar'); + $element[0].selectionStart = $element[0].selectionEnd = 4; + + trigger($element, ev); + + expect(spy).to.have.been.calledOnce; + }); + + it('triggers scope.$apply regardless', () => { + let spy = sinon.spy($scope, '$apply'); + $element[0].selectionStart = $element[0].selectionEnd = 0; + $element.val(''); + + trigger($element, ev); + + expect(spy).to.have.been.calledOnce; + }); + }); + }); + + /** + * TODO: Get ev.keyCode working. + * QT5 ain't cool with KeyBoardEvent constructors. + */ + context.skip('on keydown', () => { + let ev = 'keydown'; + + it('does nothing if not searching', () => { + let spy = sinon.spy($scope, '$apply'); + trigger($element, ev); + expect(spy).to.not.have.been.calledOnce; + }); + + it('selects if keycode 13 (return)', () => { + let spy = sinon.spy(ctrlInstance, 'select'); + ctrlInstance.searching = true; + trigger($element, ev, 13); + expect(spy).to.have.been.calledOnce; + }); + + it('goes up if keycode 38 (up)', () => { + let spy = sinon.spy(ctrlInstance, 'up'); + ctrlInstance.searching = true; + trigger($element, ev, 38); + expect(spy).to.have.been.calledOnce; + }); + + it('goes down if keycode 40 (down)', () => { + let spy = sinon.spy(ctrlInstance, 'down'); + ctrlInstance.searching = true; + trigger($element, ev, 40); + expect(spy).to.have.been.calledOnce; + }); + + context('if keycode is either 13, 38 or 40', () => { + it('sets moved to true ', () => { + ctrlInstance.searching = true; + trigger($element, ev, 13); + expect(ctrlInstance.moved).to.eq(true); + }); + + it('cancels the default of event', () => { + ctrlInstance.searching = true; + let evt = trigger($element, ev, 13); + let spy = sinon.spy(evt, 'preventDefault'); + expect(spy).to.have.been.calledOnce; + }); + + it('triggers scope.$apply', () => { + ctrlInstance.searching = true; + trigger($element, ev, 13); + let spy = sinon.spy($scope, '$apply'); + expect(spy).to.have.been.calledOnce; + }); + }); + }); + + function trigger (el, ev, code) { + let evt; + + if (code) { + evt = $document[0].createEvent('KeyboardEvent'); + evt.initKeyboardEvent(ev, true, true); + evt.keyCode = code; + } else { + evt = new Event(ev); + } + + el[0].dispatchEvent(evt); + + return evt; + } + }); +}); diff --git a/test/uiMentionDirective.spec.js b/test/uiMentionDirective.spec.js new file mode 100644 index 0000000..d46c74f --- /dev/null +++ b/test/uiMentionDirective.spec.js @@ -0,0 +1,66 @@ +describe('uiMentionDirective', () => { + let Subject, $scope, compileDir, uiMentionController; + + beforeEach(() => { + uiMentionController = function () { + uiMentionController.init = this.init = sinon.stub(); + }; + + module('ui.mention', ($controllerProvider) => { + $controllerProvider.register('uiMentionController', uiMentionController); + }); + + inject(($injector) => { + Subject = $injector.get('uiMentionDirective'); + $scope = $injector.get('$rootScope').$new(); + + compileDir = (template) => { + return $injector.get('$compile')(template)($scope); + }; + }); + }); + + context('DDO', () => { + let DDO; + + beforeEach(() => { + DDO = Subject[0]; + }); + + it('is named uiMention', () => { + expect(DDO.name).to.eq('uiMention'); + }); + + it('has a priority of 0', () => { + expect(DDO.priority).to.eq(0); + }); + + it('requires ngModel', () => { + expect(DDO.require).to.include('ngModel'); + }); + + it('requires uiMention', () => { + expect(DDO.require).to.include('uiMention'); + }); + + it('exposes controllerAs $mention', () => { + expect(DDO.controllerAs).to.eq('$mention'); + }); + + it('is restricted to EA', () => { + expect(DDO.restrict).to.eq('EA'); + }); + }); + + context('.link()', () => { + it('calls the controller.init method with the given ngModel', () => { + $scope.model = 'wat'; + compileDir(''); + $scope.$digest(); + expect(uiMentionController.init).to.have.been.calledOnce.and.calledWithMatch({ + $modelValue: 'wat' + }); + }); + }); +}); + From 59a7ce53b4924014549afbe4fd6081c2b56a451f Mon Sep 17 00:00:00 2001 From: Kasper Lewau Date: Fri, 21 Aug 2015 10:04:50 +0200 Subject: [PATCH 2/3] fix(tests): remove brittle and low value tests Testing the internal relationship between exposed API methods is brittle as we are in effect allowing modification of said API methods, rendering the tests moot. Also, refactoring the exposed API methods become harder when there's an explicit bond between them. --- test/uiMentionController.spec.js | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/test/uiMentionController.spec.js b/test/uiMentionController.spec.js index eeb3885..1b7fa7c 100644 --- a/test/uiMentionController.spec.js +++ b/test/uiMentionController.spec.js @@ -100,19 +100,13 @@ describe('uiMentionController', () => { }); it('returns the given value', () => { - expect(mentionParser('foo')).to.eq('foo'); + expect(mentionParser('foo bar')).to.eq('@[foo bar:1]'); }); it('filters out non matching mentions', () => { mentionParser('foo bar'); expect(ctrlInstance.mentions).to.eql(mentions.slice(0,1)); }); - - it('calls the controller render method with the given value', () => { - let spy = sinon.spy(ctrlInstance, 'render'); - mentionParser('foo bar'); - expect(spy).to.have.been.calledOnce.and.calledWith('@[foo bar:1]'); - }); }); context('ngModel.$formatters', () => { @@ -161,12 +155,6 @@ describe('uiMentionController', () => { ngModel.$render(); expect($element.val()).to.eq(''); }); - - it('calls the controller render method', () => { - let spy = sinon.spy(ctrlInstance, 'render'); - ngModel.$render(); - expect(spy).to.have.been.calledOnce; - }); }); }); @@ -221,12 +209,6 @@ describe('uiMentionController', () => { it('returns an HTML formatted version of the given argument', () => { expect(ctrlInstance.highlight(choice)).to.eq('x y'); }); - - it('calls the controller label method internally', () => { - let spy = sinon.spy(ctrlInstance, 'label'); - ctrlInstance.highlight(choice); - expect(spy).to.have.been.calledOnce.and.calledWith(choice); - }); }); context.skip('.decode()', () => { @@ -266,18 +248,6 @@ describe('uiMentionController', () => { expect(ctrlInstance.mentions[0]).to.eql({ first: 'foo', last: 'bar' }); }); - it('calls the controller cancel method internally', () => { - let spy = sinon.spy(ctrlInstance, 'cancel'); - ctrlInstance.select({ first: 'foo', last: 'bar' }); - expect(spy).to.have.been.calledOnce; - }); - - it('calls the ngModel.$render method internally', () => { - let spy = sinon.spy(ngModel, '$render'); - ctrlInstance.select({ first: 'foo', last: 'bar' }); - expect(spy).to.have.been.calledOnce; - }); - it('returns nothing', () => { expect(ctrlInstance.select({ first: 'foo', last: 'bar' })).to.eq(undefined); }); From 7432434de84abe66eb5168db2a00b0dc5b8656e6 Mon Sep 17 00:00:00 2001 From: Kasper Lewau Date: Fri, 21 Aug 2015 21:20:24 +0200 Subject: [PATCH 3/3] fix(tests): add missing expectations --- test/uiMentionController.spec.js | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/test/uiMentionController.spec.js b/test/uiMentionController.spec.js index 1b7fa7c..b9e0238 100644 --- a/test/uiMentionController.spec.js +++ b/test/uiMentionController.spec.js @@ -54,7 +54,7 @@ describe('uiMentionController', () => { beforeEach(function () { $scope.model = 'bar'; - $element = angular.element(''); + $element = angular.element(''); ctrlInstance = createController($element); $scope.$digest(); }); @@ -107,6 +107,11 @@ describe('uiMentionController', () => { mentionParser('foo bar'); expect(ctrlInstance.mentions).to.eql(mentions.slice(0,1)); }); + + it('updates the HTML content of the adjacent DOM element', () => { + mentionParser('foo bar'); + expect($element.next().html()).to.eq('foo bar'); + }); }); context('ngModel.$formatters', () => { @@ -155,6 +160,12 @@ describe('uiMentionController', () => { ngModel.$render(); expect($element.val()).to.eq(''); }); + + it('updates the HTML content of the adjacent DOM element', () => { + ngModel.$modelValue = '@[foo bar:1]'; + ngModel.$render(); + expect($element.next().html()).to.eq('foo bar'); + }); }); }); @@ -216,7 +227,7 @@ describe('uiMentionController', () => { }); context('.label()', () => { - it('convets the given object to a readable string', () => { + it('converts the given object to a readable string', () => { expect(ctrlInstance.label({ first: 0, last: 1 })).to.eq('0 1'); }); }); @@ -248,6 +259,16 @@ describe('uiMentionController', () => { expect(ctrlInstance.mentions[0]).to.eql({ first: 'foo', last: 'bar' }); }); + it('clears the controller choices', () => { + ctrlInstance.select({ first: 'foo', last: 'bar' }); + expect(ctrlInstance.choices).to.eql([]); + }); + + it('sets the searching regex to null', () => { + ctrlInstance.select({ first: 'foo', last: 'bar' }); + expect(ctrlInstance.searching).to.eq(null); + }); + it('returns nothing', () => { expect(ctrlInstance.select({ first: 'foo', last: 'bar' })).to.eq(undefined); });