From 628433e572a74b21109c0ed1684bacae8e2dde30 Mon Sep 17 00:00:00 2001 From: weihanchen Date: Thu, 25 May 2017 22:35:50 +0800 Subject: [PATCH] feat(tagsInput): Allow double click to edit and split tag by input-split-pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add allowDblclickToEdit、inputSplitPattern options so that you can use double click to edit current tag and split tag by pattern Update general test spec --- src/init.js | 2 + src/selectall.js | 31 +++++++ src/tags-input.js | 94 ++++++++++++++++----- templates/tags-input.html | 23 ++++-- test/selectall.spec.js | 44 ++++++++++ test/tags-input.spec.js | 167 +++++++++++++++++++++++++++++++++----- 6 files changed, 314 insertions(+), 47 deletions(-) create mode 100644 src/selectall.js create mode 100644 test/selectall.spec.js diff --git a/src/init.js b/src/init.js index 9a001928..b6f3f017 100644 --- a/src/init.js +++ b/src/init.js @@ -6,6 +6,7 @@ import AutocompleteDirective from './auto-complete'; import AutocompleteMatchDirective from './auto-complete-match'; import AutosizeDirective from './autosize'; import BindAttributesDirective from './bind-attrs'; +import SelectallDirective from './selectall'; import TranscludeAppendDirective from './transclude-append'; import TagsInputConfigurationProvider from './configuration'; import UtilService from './util'; @@ -19,6 +20,7 @@ angular.module('ngTagsInput', []) .directive('tiAutosize', AutosizeDirective) .directive('tiBindAttrs', BindAttributesDirective) .directive('tiTranscludeAppend', TranscludeAppendDirective) + .directive('tiSelectall', SelectallDirective) .factory('tiUtil', UtilService) .constant('tiConstants', Constants) .provider('tagsInputConfig', TagsInputConfigurationProvider) diff --git a/src/selectall.js b/src/selectall.js new file mode 100644 index 00000000..019d6d90 --- /dev/null +++ b/src/selectall.js @@ -0,0 +1,31 @@ +/** + * @ngdoc directive + * @name tiSelectall + * @module ngTagsInput + * + * @description + * Automatically select all and focus the input. Used internally by tagsInput directive. + */ + +export default function SelectallDirective($timeout, $parse) { + 'ngInject'; + return { + scope: {}, + link(scope, element, attrs) { + scope.selectAll = false; + let model = $parse(attrs.tiSelectall); + let selectAll = () => { + $timeout(() => { + element[0].focus(); + element[0].select(); + }); + }; + scope.$watch(model, (value) =>{ + if (value === true) { + selectAll(); + } + scope.selectAll = value; + }); + } + }; +} \ No newline at end of file diff --git a/src/tags-input.js b/src/tags-input.js index 8158a3d4..4e8ed734 100644 --- a/src/tags-input.js +++ b/src/tags-input.js @@ -52,6 +52,8 @@ * promise is returned, the tag will not be removed. * @param {expression=} [onTagRemoved=NA] Expression to evaluate upon removing an existing tag. The removed tag is available as $tag. * @param {expression=} [onTagClicked=NA] Expression to evaluate upon clicking an existing tag. The clicked tag is available as $tag. + * @param {boolean=} [allowDblclickToEdit=false] Flag indicating that allow double click to edit current tag. + * @param {string=} [inputSplitPattern=null] Regular expression that split edit input tags. */ export default function TagsInputDirective($timeout, $document, $window, $q, tagsInputConfig, tiUtil, tiConstants) { 'ngInject'; @@ -59,7 +61,7 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag function TagList(options, events, onTagAdding, onTagRemoving) { let self = {}; - let getTagText = tag =>tiUtil.safeToString(tag[options.displayProperty]); + let getTagText = tag => tiUtil.safeToString(tag[options.displayProperty]); let setTagText = (tag, text) => { tag[options.displayProperty] = text; }; @@ -67,10 +69,10 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag let canAddTag = tag => { let tagText = getTagText(tag); let valid = tagText && - tagText.length >= options.minLength && - tagText.length <= options.maxLength && - options.allowedTagsPattern.test(tagText) && - !tiUtil.findInObjectArray(self.items, tag, options.keyProperty || options.displayProperty); + tagText.length >= options.minLength && + tagText.length <= options.maxLength && + options.allowedTagsPattern.test(tagText) && + !tiUtil.findInObjectArray(self.items, tag, options.keyProperty || options.displayProperty); return $q.when(valid && onTagAdding({ $tag: tag })).then(tiUtil.promisifyValue); }; @@ -85,6 +87,10 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag return self.add(tag); }; + self.addTextArr = textArr => { + textArr.forEach(text => self.addText(text)); + } + self.add = tag => { let tagText = getTagText(tag); @@ -95,7 +101,7 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag setTagText(tag, tagText); return canAddTag(tag) - .then(() =>{ + .then(() => { self.items.push(tag); events.trigger('tag-added', { $tag: tag }); }) @@ -201,6 +207,8 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag allowLeftoverText: [Boolean, false], addFromAutocompleteOnly: [Boolean, false], spellcheck: [Boolean, true], + allowDblclickToEdit: [Boolean, false], + inputSplitPattern: [RegExp, null], useStrings: [Boolean, false] }); @@ -209,22 +217,22 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag tiUtil.handleUndefinedResult($scope.onTagRemoving, true)); this.registerAutocomplete = () => ({ - addTag: function(tag) { + addTag: function (tag) { return $scope.tagList.add(tag); }, - getTags: function() { + getTags: function () { return $scope.tagList.items; }, - getCurrentTagText: function() { + getCurrentTagText: function () { return $scope.newTag.text(); }, - getOptions: function() { + getOptions: function () { return $scope.options; }, - getTemplateScope: function() { + getTemplateScope: function () { return $scope.templateScope; }, - on: function(name, handler) { + on: function (name, handler) { $scope.events.on(name, handler, true); return this; } @@ -263,6 +271,20 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag ngModelCtrl.$isEmpty = value => !value || !value.length; + scope.isEditing = false; + + scope.editingTag = { + text(value) { + if (angular.isDefined(value)) { + scope.editingText = value; + events.trigger('edit-input-change', value); + } else { + return scope.editingText || ''; + } + }, + invalid: null + }; + scope.newTag = { text(value) { if (angular.isDefined(value)) { @@ -281,8 +303,8 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag scope.getTagClass = (tag, index) => { let selected = tag === tagList.selected; return [ - scope.tagClass({$tag: tag, $index: index, $selected: selected}), - { selected: selected } + scope.tagClass({ $tag: tag, $index: index, $selected: selected }), + { selected: selected } ]; }; @@ -337,6 +359,9 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag } }); }, + editBlur($event, tag) { + events.trigger('edit-input-blur', tag); + }, paste($event) { $event.getTextData = () => { let clipboardData = $event.clipboardData || ($event.originalEvent && $event.originalEvent.clipboardData); @@ -356,6 +381,9 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag tag: { click(tag) { events.trigger('tag-clicked', { $tag: tag }); + }, + dblclick(tag) { + events.trigger('tag-dblclicked', tag); } } }; @@ -365,6 +393,13 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag .on('invalid-tag', scope.onInvalidTag) .on('tag-removed', scope.onTagRemoved) .on('tag-clicked', scope.onTagClicked) + .on('tag-dblclicked', (tag) => { + if (options.allowDblclickToEdit) { + scope.editingTag.text(tag.text); + tag.editable = true; + scope.isEditing = true; + } + }) .on('tag-added', () => { scope.newTag.text(''); }) @@ -394,11 +429,26 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag }) .on('input-blur', () => { if (options.addOnBlur && !options.addFromAutocompleteOnly) { - tagList.addText(scope.newTag.text()); + let tags = scope.newTag.text().split(options.inputSplitPattern); + tagList.addTextArr(tags); } element.triggerHandler('blur'); setElementValidity(); }) + .on('edit-input-blur', tag => { + let editingText = scope.editingTag.text(); + let tags = editingText.split(options.inputSplitPattern); + let firstTagText = tags.shift(); + tag.text = firstTagText; + tagList.addTextArr(tags); + tag.editable = false; + scope.isEditing = false; + focusInput(); + }) + .on('edit-input-change', () => { + tagList.clearSelection(); + scope.editingTag.invalid = null; + }) .on('input-keydown', event => { let key = event.keyCode; @@ -414,12 +464,17 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag let shouldAdd = !options.addFromAutocompleteOnly && addKeys[key]; let shouldRemove = (key === tiConstants.KEYS.backspace || key === tiConstants.KEYS.delete) && tagList.selected; - let shouldEditLastTag = key === tiConstants.KEYS.backspace && scope.newTag.text().length === 0 && options.enableEditingLastTag; + let shouldEditLastTag = key === tiConstants.KEYS.backspace && scope.newTag.text().length === 0 && options.enableEditingLastTag && !scope.isEditing; let shouldSelect = (key === tiConstants.KEYS.backspace || key === tiConstants.KEYS.left || key === tiConstants.KEYS.right) && scope.newTag.text().length === 0 && !options.enableEditingLastTag; if (shouldAdd) { - tagList.addText(scope.newTag.text()); + if (scope.isEditing) { + element.find('input')[0].blur(); + return; + } + let tags = scope.newTag.text().split(options.inputSplitPattern); + tagList.addTextArr(tags); } else if (shouldEditLastTag) { tagList.selectPrior(); @@ -451,9 +506,7 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag let tags = data.split(options.pasteSplitPattern); if (tags.length > 1) { - tags.forEach(tag => { - tagList.addText(tag); - }); + tagList.addTextArr(tags); event.preventDefault(); } } @@ -461,3 +514,4 @@ export default function TagsInputDirective($timeout, $document, $window, $q, tag } }; } + diff --git a/templates/tags-input.html b/templates/tags-input.html index 81e260ff..68cf3e8a 100644 --- a/templates/tags-input.html +++ b/templates/tags-input.html @@ -1,11 +1,24 @@
    -
  • - + ng-click="eventHandlers.tag.click(tag)" + ng-dblclick="eventHandlers.tag.dblclick(tag)"> + +
-
\ No newline at end of file + diff --git a/test/selectall.spec.js b/test/selectall.spec.js new file mode 100644 index 00000000..5803325f --- /dev/null +++ b/test/selectall.spec.js @@ -0,0 +1,44 @@ +'use strict'; + +describe('selectall directive', () => { + var $scope, $compile, element, $timeout, isolateScope; + beforeEach(() => { + module('ngTagsInput'); + + inject(($rootScope, _$compile_, _$timeout_) => { + $scope = $scope = $rootScope.$new(); + $compile = _$compile_; + $timeout = _$timeout_; + }); + + }); + + function compile() { + let attributes = $.makeArray(arguments).join(' '); + + element = angular.element(''); + $compile(element)($scope); + isolateScope = element.isolateScope(); + $scope.$digest(); + } + + it('input select all and focus', () => { + // Act/Arrange + compile('ti-selectall="true"'); + $timeout.flush(); + + //Assert + expect(isolateScope.selectAll).toBe(true); + }); + + it('input not select all and focus', () => { + // Act/Arrange + compile('ti-selectall="false"'); + $timeout.flush(); + + //Assert + expect(isolateScope.selectAll).toBe(false); + }); + + +}); \ No newline at end of file diff --git a/test/tags-input.spec.js b/test/tags-input.spec.js index af1582f1..8a53ca81 100644 --- a/test/tags-input.spec.js +++ b/test/tags-input.spec.js @@ -56,8 +56,22 @@ describe('tags-input directive', () => { return getTag(index).find('ti-tag-item > ng-include > a').first(); } - function getInput() { - return element.find('input'); + function getEditInput() { + return getInput('li > input'); + } + + function getInput(selector) { + selector = selector || 'input'; + return element.find(selector); + } + + function editTag(tag, key) { + let editSelector = 'li > input'; + key = key || constants.KEYS.enter; + for (var i = 0; i < tag.length; i++) { + sendKeyPress(tag.charCodeAt(i), editSelector); + } + sendKeyDown(key, editSelector); } function newTag(tag, key) { @@ -70,9 +84,9 @@ describe('tags-input directive', () => { sendKeyDown(key); } - function sendKeyPress(charCode) { - let input = getInput(); - let event = jQuery.Event('keypress', {charCode: charCode}); + function sendKeyPress(charCode, selector) { + let input = getInput(selector); + let event = jQuery.Event('keypress', { charCode: charCode }); input.trigger(event); if (!event.isDefaultPrevented()) { @@ -80,25 +94,25 @@ describe('tags-input directive', () => { } } - function sendKeyDown(keyCode, properties) { - let event = jQuery.Event('keydown', angular.extend({keyCode: keyCode}, properties || {})); - getInput().trigger(event); + function sendKeyDown(keyCode, properties, selector) { + let event = jQuery.Event('keydown', angular.extend({ keyCode: keyCode }, properties || {})); + getInput(selector).trigger(event); return event; } - function sendBackspace() { + function sendBackspace(selector) { let event = sendKeyDown(constants.KEYS.backspace); if (!event.isDefaultPrevented()) { - let input = getInput(); + let input = getInput(selector); let value = input.val(); changeInputValue(value.substr(0, value.length - 1)); } } - function changeInputValue(value) { - changeElementValue(getInput(), value); + function changeInputValue(value, selector) { + changeElementValue(getInput(selector), value); } describe('basic features', () => { @@ -113,9 +127,9 @@ describe('tags-input directive', () => { it('renders the correct number of tags', () => { // Arrange $scope.tags = [ - { text: 'Tag1 '}, - { text: ' Tag2'}, - { text: ' Tag3 '} + { text: 'Tag1 ' }, + { text: ' Tag2' }, + { text: ' Tag3 ' } ]; // Act @@ -934,6 +948,33 @@ describe('tags-input directive', () => { }); }); + describe('input-split-pattern option', () => { + it('initializes the option to null', () => { + //Arrange/Act + compile(); + //Assert + expect(isolateScope.options.inputSplitPattern).toEqual(null); + }); + + it('splits the input text into tags using the provided pattern', () => { + // Arrange + compile('input-split-pattern="\\s+|,|-"'); + // Act + newTag('Tag1 Tag2,Tag3-Tag4'); + + // Assert + expect($scope.tags).toEqual([{ + text: 'Tag1' + }, { + text: 'Tag2' + }, { + text: 'Tag3' + }, { + text: 'Tag4' + }]); + }); + }); + describe('min-length option', () => { it('initializes the option to 3', () => { // Arrange/Act @@ -969,6 +1010,65 @@ describe('tags-input directive', () => { }); }); + describe('allow-dblclick-to-edit option', () => { + beforeEach(() => { + $scope.tags = [{ + text: 'Tag1' + }, { + text: 'Tag2' + }, { + text: 'Tag3' + }]; + }); + + it('initializes the option to false', () => { + // Arrange/Act + compile(); + // Assert + expect(isolateScope.options.allowDblclickToEdit).toBe(false); + }); + + + describe('option is on', () => { + beforeEach(() => { + compile('add-on-enter="true" allow-dblclick-to-edit="true"'); + }); + + describe('double click action', () => { + it('trigger dblclick and validate edit input value', () => { + // Arrange/Act + getTag(1).dblclick(); + $scope.$digest(); + + // Assert + expect(getEditInput().val()).toBe('Tag2'); + }); + + it('updated edit input value to current tag with blur', () => { + // Arrange/Act + getTag(1).dblclick(); + $scope.$digest(); + changeElementValue(getEditInput(), 'EditedTag'); + getEditInput().blur(); + + // Assert + expect(getTagText(1)).toBe('EditedTag'); + }); + + it('updated edit input value to current tag with keypress', () => { + // Arrange/Act + getTag(1).dblclick(); + $scope.$digest(); + changeElementValue(getEditInput(), ''); + editTag('EditedTag'); + + //Assert + expect(getEditInput().val()).toBe('EditedTag'); + }); + }); + }); + }); + describe('max-length option', () => { it('initializes the option to MAX_SAFE_INTEGER', () => { // Arrange/Act @@ -1321,7 +1421,7 @@ describe('tags-input directive', () => { it('renders tags with duplicate labels but different keys', () => { // Arrange - $scope.tags= [ + $scope.tags = [ { id: 1, text: 'Tag' }, { id: 2, text: 'Tag' } ]; @@ -1336,7 +1436,7 @@ describe('tags-input directive', () => { it('fails to render tags with duplicate keys', () => { // Arrange - $scope.tags= [ + $scope.tags = [ { id: 1, text: 'Tag' }, { id: 1, text: 'Tag' } ]; @@ -2150,6 +2250,14 @@ describe('tags-input directive', () => { expect(autocompleteObj.getOptions()).toEqual({ option1: 1, option2: 2, option3: true }); }); + it('return current scope', () => { + // Arrange + isolateScope.templateScope = $scope; + + // Act/Assert + expect(autocompleteObj.getTemplateScope()).toEqual($scope); + }); + it('returns the scope for custom templates', () => { // Arrange isolateScope.templateScope = { prop: 'foobar', method: jasmine.createSpy().and.returnValue(42) }; @@ -2181,10 +2289,10 @@ describe('tags-input directive', () => { // Act/Assert hotkeys.forEach(hotkey => { - expect(sendKeyDown(hotkey, {shiftKey: true}).isDefaultPrevented()).toBe(false); - expect(sendKeyDown(hotkey, {altKey: true}).isDefaultPrevented()).toBe(false); - expect(sendKeyDown(hotkey, {ctrlKey: true}).isDefaultPrevented()).toBe(false); - expect(sendKeyDown(hotkey, {metaKey: true}).isDefaultPrevented()).toBe(false); + expect(sendKeyDown(hotkey, { shiftKey: true }).isDefaultPrevented()).toBe(false); + expect(sendKeyDown(hotkey, { altKey: true }).isDefaultPrevented()).toBe(false); + expect(sendKeyDown(hotkey, { ctrlKey: true }).isDefaultPrevented()).toBe(false); + expect(sendKeyDown(hotkey, { metaKey: true }).isDefaultPrevented()).toBe(false); }); }); }); @@ -2213,5 +2321,20 @@ describe('tags-input directive', () => { expect(sendKeyDown(constants.KEYS.backspace).isDefaultPrevented()).toBe(true); }); }); + + describe('unexist options', () => { + it('make use nuexist option', () => { + // Arrange + compile('nuexist="true"'); + $scope.$digest(); + + // Act + isolateScope.events.trigger('option-change', { name: 'nuexist' }); + $scope.$digest(); + + // Assert + expect(isolateScope.options.nuexist).toBeUndefined(); + }); + }); }); -}); +}); \ No newline at end of file