changeset 1261:4f5d10466283 redmine-2.2-integration

Updated the TagIt script to the latest version.
author luisf <luis.figueira@eecs.qmul.ac.uk>
date Thu, 11 Apr 2013 15:09:12 +0100
parents b18f581b260a
children 8fee6c95fb35
files plugins/redmine_tags/assets/javascripts/tag-it.js
diffstat 1 files changed, 259 insertions(+), 113 deletions(-) [+]
line wrap: on
line diff
--- a/plugins/redmine_tags/assets/javascripts/tag-it.js	Thu Apr 11 12:34:22 2013 +0100
+++ b/plugins/redmine_tags/assets/javascripts/tag-it.js	Thu Apr 11 15:09:12 2013 +0100
@@ -28,21 +28,29 @@
 
     $.widget('ui.tagit', {
         options: {
-            itemName          : 'item',
+            allowDuplicates   : false,
+            caseSensitive     : true,
             fieldName         : 'tags',
+            placeholderText   : null,   // Sets `placeholder` attr on input field.
+            readOnly          : false,  // Disables editing.
+            removeConfirmation: false,  // Require confirmation to remove tags.
+            tagLimit          : null,   // Max number of tags allowed (null for unlimited).
+
+            // Used for autocomplete, unless you override `autocomplete.source`.
             availableTags     : [],
-            tagSource         : null,
-            removeConfirmation: false,
-            caseSensitive     : true,
-            placeholderText   : null,
 
-            // When enabled, quotes are not neccesary
-            // for inputting multi-word tags.
+            // Use to override or add any options to the autocomplete widget.
+            //
+            // By default, autocomplete.source will map to availableTags,
+            // unless overridden.
+            autocomplete: {},
+
+            // Shows autocomplete before the user even types anything.
+            showAutocompleteOnFocus: false,
+
+            // When enabled, quotes are unneccesary for inputting multi-word tags.
             allowSpaces: false,
 
-            // Whether to animate tag removals or not.
-            animate: true,
-
             // The below options are for using a single field instead of several
             // for our form values.
             //
@@ -52,10 +60,12 @@
             //
             // The easiest way to use singleField is to just instantiate tag-it
             // on an INPUT element, in which case singleField is automatically
-            // set to true, and singleFieldNode is set to that element. This 
+            // set to true, and singleFieldNode is set to that element. This
             // way, you don't need to fiddle with these options.
             singleField: false,
 
+            // This is just used when preloading data from the field, and for
+            // populating the field with delimited tags as the user adds them.
             singleFieldDelimiter: ',',
 
             // Set this to an input DOM node to use an existing form field.
@@ -64,22 +74,39 @@
             // delimited by singleFieldDelimiter.
             //
             // If this is not set, we create an input node for it,
-            // with the name given in settings.fieldName, 
-            // ignoring settings.itemName.
+            // with the name given in settings.fieldName.
             singleFieldNode: null,
 
+            // Whether to animate tag removals or not.
+            animate: true,
+
             // Optionally set a tabindex attribute on the input that gets
             // created for tag-it.
             tabIndex: null,
 
+            // Event callbacks.
+            beforeTagAdded      : null,
+            afterTagAdded       : null,
 
-            // Event callbacks.
+            beforeTagRemoved    : null,
+            afterTagRemoved     : null,
+
+            onTagClicked        : null,
+            onTagLimitExceeded  : null,
+
+
+            // DEPRECATED:
+            //
+            // /!\ These event callbacks are deprecated and WILL BE REMOVED at some
+            // point in the future. They're here for backwards-compatibility.
+            // Use the above before/after event callbacks instead.
             onTagAdded  : null,
             onTagRemoved: null,
-            onTagClicked: null
+            // `autocomplete.source` is the replacement for tagSource.
+            tagSource: null
+            // Do not use the above deprecated options.
         },
 
-
         _create: function() {
             // for handling static scoping inside callbacks
             var that = this;
@@ -97,25 +124,46 @@
                 this.tagList = this.element.find('ul, ol').andSelf().last();
             }
 
-            this._tagInput = $('<input type="text" />').addClass('ui-widget-content');
+            this.tagInput = $('<input type="text" />').addClass('ui-widget-content');
+
+            if (this.options.readOnly) this.tagInput.attr('disabled', 'disabled');
+
             if (this.options.tabIndex) {
-                this._tagInput.attr('tabindex', this.options.tabIndex);
-            }
-            if (this.options.placeholderText) {
-                this._tagInput.attr('placeholder', this.options.placeholderText);
+                this.tagInput.attr('tabindex', this.options.tabIndex);
             }
 
-            this.options.tagSource = this.options.tagSource || function(search, showChoices) {
-                var filter = search.term.toLowerCase();
-                var choices = $.grep(this.options.availableTags, function(element) {
-                    // Only match autocomplete options that begin with the search term.
-                    // (Case insensitive.)
-                    return (element.toLowerCase().indexOf(filter) === 0);
+            if (this.options.placeholderText) {
+                this.tagInput.attr('placeholder', this.options.placeholderText);
+            }
+
+            if (!this.options.autocomplete.source) {
+                this.options.autocomplete.source = function(search, showChoices) {
+                    var filter = search.term.toLowerCase();
+                    var choices = $.grep(this.options.availableTags, function(element) {
+                        // Only match autocomplete options that begin with the search term.
+                        // (Case insensitive.)
+                        return (element.toLowerCase().indexOf(filter) === 0);
+                    });
+                    showChoices(this._subtractArray(choices, this.assignedTags()));
+                };
+            }
+
+            if (this.options.showAutocompleteOnFocus) {
+                this.tagInput.focus(function(event, ui) {
+                    that._showAutocomplete();
                 });
-                showChoices(this._subtractArray(choices, this.assignedTags()));
-            };
 
-            // Bind tagSource callback functions to this context.
+                if (typeof this.options.autocomplete.minLength === 'undefined') {
+                    this.options.autocomplete.minLength = 0;
+                }
+            }
+
+            // Bind autocomplete.source callback functions to this context.
+            if ($.isFunction(this.options.autocomplete.source)) {
+                this.options.autocomplete.source = $.proxy(this.options.autocomplete.source, this);
+            }
+
+            // DEPRECATED.
             if ($.isFunction(this.options.tagSource)) {
                 this.options.tagSource = $.proxy(this.options.tagSource, this);
             }
@@ -124,28 +172,24 @@
                 .addClass('tagit')
                 .addClass('ui-widget ui-widget-content ui-corner-all')
                 // Create the input field.
-                .append($('<li class="tagit-new"></li>').append(this._tagInput))
+                .append($('<li class="tagit-new"></li>').append(this.tagInput))
                 .click(function(e) {
                     var target = $(e.target);
                     if (target.hasClass('tagit-label')) {
-                        that._trigger('onTagClicked', e, target.closest('.tagit-choice'));
+                        var tag = target.closest('.tagit-choice');
+                        if (!tag.hasClass('removed')) {
+                            that._trigger('onTagClicked', e, {tag: tag, tagLabel: that.tagLabel(tag)});
+                        }
                     } else {
                         // Sets the focus() to the input field, if the user
                         // clicks anywhere inside the UL. This is needed
                         // because the input field needs to be of a small size.
-                        that._tagInput.focus();
+                        that.tagInput.focus();
                     }
                 });
 
-            // Add existing tags from the list, if any.
-            this.tagList.children('li').each(function() {
-                if (!$(this).hasClass('tagit-new')) {
-                    that.createTag($(this).html(), $(this).attr('class'));
-                    $(this).remove();
-                }
-            });
-
             // Single field support.
+            var addedExistingFromSingleFieldNode = false;
             if (this.options.singleField) {
                 if (this.options.singleFieldNode) {
                     // Add existing tags from the input field.
@@ -153,19 +197,31 @@
                     var tags = node.val().split(this.options.singleFieldDelimiter);
                     node.val('');
                     $.each(tags, function(index, tag) {
-                        that.createTag(tag);
+                        that.createTag(tag, null, true);
+                        addedExistingFromSingleFieldNode = true;
                     });
                 } else {
                     // Create our single field input after our list.
-                    this.options.singleFieldNode = this.tagList.after('<input type="hidden" style="display:none;" value="" name="' + this.options.fieldName + '" />');
+                    this.options.singleFieldNode = $('<input type="hidden" style="display:none;" value="" name="' + this.options.fieldName + '" />');
+                    this.tagList.after(this.options.singleFieldNode);
                 }
             }
 
+            // Add existing tags from the list, if any.
+            if (!addedExistingFromSingleFieldNode) {
+                this.tagList.children('li').each(function() {
+                    if (!$(this).hasClass('tagit-new')) {
+                        that.createTag($(this).text(), $(this).attr('class'), true);
+                        $(this).remove();
+                    }
+                });
+            }
+
             // Events.
-            this._tagInput
+            this.tagInput
                 .keydown(function(event) {
                     // Backspace is not detected within a keypress, so it must use keydown.
-                    if (event.which == $.ui.keyCode.BACKSPACE && that._tagInput.val() === '') {
+                    if (event.which == $.ui.keyCode.BACKSPACE && that.tagInput.val() === '') {
                         var tag = that._lastTag();
                         if (!that.options.removeConfirmation || tag.hasClass('remove')) {
                             // When backspace is pressed, the last tag is deleted.
@@ -179,69 +235,81 @@
 
                     // Comma/Space/Enter are all valid delimiters for new tags,
                     // except when there is an open quote or if setting allowSpaces = true.
-                    // Tab will also create a tag, unless the tag input is empty, in which case it isn't caught.
+                    // Tab will also create a tag, unless the tag input is empty,
+                    // in which case it isn't caught.
                     if (
-                        event.which == $.ui.keyCode.COMMA ||
-                        event.which == $.ui.keyCode.ENTER ||
+                        event.which === $.ui.keyCode.COMMA ||
+                        event.which === $.ui.keyCode.ENTER ||
                         (
                             event.which == $.ui.keyCode.TAB &&
-                            that._tagInput.val() !== ''
+                            that.tagInput.val() !== ''
                         ) ||
                         (
                             event.which == $.ui.keyCode.SPACE &&
                             that.options.allowSpaces !== true &&
                             (
-                                $.trim(that._tagInput.val()).replace( /^s*/, '' ).charAt(0) != '"' ||
+                                $.trim(that.tagInput.val()).replace( /^s*/, '' ).charAt(0) != '"' ||
                                 (
-                                    $.trim(that._tagInput.val()).charAt(0) == '"' &&
-                                    $.trim(that._tagInput.val()).charAt($.trim(that._tagInput.val()).length - 1) == '"' &&
-                                    $.trim(that._tagInput.val()).length - 1 !== 0
+                                    $.trim(that.tagInput.val()).charAt(0) == '"' &&
+                                    $.trim(that.tagInput.val()).charAt($.trim(that.tagInput.val()).length - 1) == '"' &&
+                                    $.trim(that.tagInput.val()).length - 1 !== 0
                                 )
                             )
                         )
                     ) {
-                        event.preventDefault();
+                        // Enter submits the form if there's no text in the input.
+                        if (!(event.which === $.ui.keyCode.ENTER && that.tagInput.val() === '')) {
+                            event.preventDefault();
+                        }
+
                         that.createTag(that._cleanedInput());
 
                         // The autocomplete doesn't close automatically when TAB is pressed.
                         // So let's ensure that it closes.
-                        that._tagInput.autocomplete('close');
+                        that.tagInput.autocomplete('close');
                     }
                 }).blur(function(e){
-                    // Create a tag when the element loses focus (unless it's empty).
-                    that.createTag(that._cleanedInput());
+                    // Create a tag when the element loses focus.
+                    // If autocomplete is enabled and suggestion was clicked, don't add it.
+                    if (!that.tagInput.data('autocomplete-open')) {
+                        that.createTag(that._cleanedInput());
+                    }
                 });
-                
 
             // Autocomplete.
-            if (this.options.availableTags || this.options.tagSource) {
-                this._tagInput.autocomplete({
-                    source: this.options.tagSource,
+            if (this.options.availableTags || this.options.tagSource || this.options.autocomplete.source) {
+                var autocompleteOptions = {
                     select: function(event, ui) {
-                        // Delete the last tag if we autocomplete something despite the input being empty
-                        // This happens because the input's blur event causes the tag to be created when
-                        // the user clicks an autocomplete item.
-                        // The only artifact of this is that while the user holds down the mouse button
-                        // on the selected autocomplete item, a tag is shown with the pre-autocompleted text,
-                        // and is changed to the autocompleted text upon mouseup.
-                        if (that._tagInput.val() === '') {
-                            that.removeTag(that._lastTag(), false);
-                        }
                         that.createTag(ui.item.value);
                         // Preventing the tag input to be updated with the chosen value.
                         return false;
                     }
+                };
+                $.extend(autocompleteOptions, this.options.autocomplete);
+
+                // tagSource is deprecated, but takes precedence here since autocomplete.source is set by default,
+                // while tagSource is left null by default.
+                autocompleteOptions.source = this.options.tagSource || autocompleteOptions.source;
+
+                this.tagInput.autocomplete(autocompleteOptions).bind('autocompleteopen', function(event, ui) {
+                    that.tagInput.data('autocomplete-open', true);
+                }).bind('autocompleteclose', function(event, ui) {
+                    that.tagInput.data('autocomplete-open', false)
                 });
             }
         },
 
         _cleanedInput: function() {
             // Returns the contents of the tag input, cleaned and ready to be passed to createTag
-            return $.trim(this._tagInput.val().replace(/^"(.*)"$/, '$1'));
+            return $.trim(this.tagInput.val().replace(/^"(.*)"$/, '$1'));
         },
 
         _lastTag: function() {
-            return this.tagList.children('.tagit-choice:last');
+            return this.tagList.find('.tagit-choice:last:not(.removed)');
+        },
+
+        _tags: function() {
+            return this.tagList.find('.tagit-choice:not(.removed)');
         },
 
         assignedTags: function() {
@@ -254,7 +322,7 @@
                     tags = [];
                 }
             } else {
-                this.tagList.children('.tagit-choice').each(function() {
+                this._tags().each(function() {
                     tags.push(that.tagLabel(this));
                 });
             }
@@ -263,7 +331,7 @@
 
         _updateSingleTagsField: function(tags) {
             // Takes a list of tag string values, updates this.options.singleFieldNode.val to the tags delimited by this.options.singleFieldDelimiter
-            $(this.options.singleFieldNode).val(tags.join(this.options.singleFieldDelimiter));
+            $(this.options.singleFieldNode).val(tags.join(this.options.singleFieldDelimiter)).trigger('change');
         },
 
         _subtractArray: function(a1, a2) {
@@ -279,22 +347,30 @@
         tagLabel: function(tag) {
             // Returns the tag's string label.
             if (this.options.singleField) {
-                return $(tag).children('.tagit-label').text();
+                return $(tag).find('.tagit-label:first').text();
             } else {
-                return $(tag).children('input').val();
+                return $(tag).find('input:first').val();
             }
         },
 
-        _isNew: function(value) {
+        _showAutocomplete: function() {
+            this.tagInput.autocomplete('search', '');
+        },
+
+        _findTagByLabel: function(name) {
             var that = this;
-            var isNew = true;
-            this.tagList.children('.tagit-choice').each(function(i) {
-                if (that._formatStr(value) == that._formatStr(that.tagLabel(this))) {
-                    isNew = false;
+            var tag = null;
+            this._tags().each(function(i) {
+                if (that._formatStr(name) == that._formatStr(that.tagLabel(this))) {
+                    tag = $(this);
                     return false;
                 }
             });
-            return isNew;
+            return tag;
+        },
+
+        _isNew: function(name) {
+            return !this._findTagByLabel(name);
         },
 
         _formatStr: function(str) {
@@ -304,12 +380,38 @@
             return $.trim(str.toLowerCase());
         },
 
-        createTag: function(value, additionalClass) {
+        _effectExists: function(name) {
+            return Boolean($.effects && ($.effects[name] || ($.effects.effect && $.effects.effect[name])));
+        },
+
+        createTag: function(value, additionalClass, duringInitialization) {
             var that = this;
-            // Automatically trims the value of leading and trailing whitespace.
+
             value = $.trim(value);
 
-            if (!this._isNew(value) || value === '') {
+            if(this.options.preprocessTag) {
+                value = this.options.preprocessTag(value);
+            }
+
+            if (value === '') {
+                return false;
+            }
+
+            if (!this.options.allowDuplicates && !this._isNew(value)) {
+                var existingTag = this._findTagByLabel(value);
+                if (this._trigger('onTagExists', null, {
+                    existingTag: existingTag,
+                    duringInitialization: duringInitialization
+                }) !== false) {
+                    if (this._effectExists('highlight')) {
+                        existingTag.effect('highlight');
+                    }
+                }
+                return false;
+            }
+
+            if (this.options.tagLimit && this._tags().length >= this.options.tagLimit) {
+                this._trigger('onTagLimitExceeded', null, {duringInitialization: duringInitialization});
                 return false;
             }
 
@@ -321,44 +423,74 @@
                 .addClass(additionalClass)
                 .append(label);
 
-            // Button for removing the tag.
-            var removeTagIcon = $('<span></span>')
-                .addClass('ui-icon ui-icon-close');
-            var removeTag = $('<a><span class="text-icon">\xd7</span></a>') // \xd7 is an X
-                .addClass('tagit-close')
-                .append(removeTagIcon)
-                .click(function(e) {
-                    // Removes a tag when the little 'x' is clicked.
-                    that.removeTag(tag);
-                });
-            tag.append(removeTag);
+            if (this.options.readOnly){
+                tag.addClass('tagit-choice-read-only');
+            } else {
+                tag.addClass('tagit-choice-editable');
+                // Button for removing the tag.
+                var removeTagIcon = $('<span></span>')
+                    .addClass('ui-icon ui-icon-close');
+                var removeTag = $('<a><span class="text-icon">\xd7</span></a>') // \xd7 is an X
+                    .addClass('tagit-close')
+                    .append(removeTagIcon)
+                    .click(function(e) {
+                        // Removes a tag when the little 'x' is clicked.
+                        that.removeTag(tag);
+                    });
+                tag.append(removeTag);
+            }
 
             // Unless options.singleField is set, each tag has a hidden input field inline.
+            if (!this.options.singleField) {
+                var escapedValue = label.html();
+                tag.append('<input type="hidden" style="display:none;" value="' + escapedValue + '" name="' + this.options.fieldName + '" />');
+            }
+
+            if (this._trigger('beforeTagAdded', null, {
+                tag: tag,
+                tagLabel: this.tagLabel(tag),
+                duringInitialization: duringInitialization
+            }) === false) {
+                return;
+            }
+
             if (this.options.singleField) {
                 var tags = this.assignedTags();
                 tags.push(value);
                 this._updateSingleTagsField(tags);
-            } else {
-                var escapedValue = label.html();
-                tag.append('<input type="hidden" style="display:none;" value="' + escapedValue + '" name="' + this.options.itemName + '[' + this.options.fieldName + '][]" />');
             }
 
+            // DEPRECATED.
             this._trigger('onTagAdded', null, tag);
 
-            // Cleaning the input.
-            this._tagInput.val('');
+            this.tagInput.val('');
 
-            // insert tag
-            this._tagInput.parent().before(tag);
+            // Insert tag.
+            this.tagInput.parent().before(tag);
+
+            this._trigger('afterTagAdded', null, {
+                tag: tag,
+                tagLabel: this.tagLabel(tag),
+                duringInitialization: duringInitialization
+            });
+
+            if (this.options.showAutocompleteOnFocus && !duringInitialization) {
+                setTimeout(function () { that._showAutocomplete(); }, 0);
+            }
         },
-        
+
         removeTag: function(tag, animate) {
-            animate = animate || this.options.animate;
+            animate = typeof animate === 'undefined' ? this.options.animate : animate;
 
             tag = $(tag);
 
+            // DEPRECATED.
             this._trigger('onTagRemoved', null, tag);
 
+            if (this._trigger('beforeTagRemoved', null, {tag: tag, tagLabel: this.tagLabel(tag)}) === false) {
+                return;
+            }
+
             if (this.options.singleField) {
                 var tags = this.assignedTags();
                 var removedTagLabel = this.tagLabel(tag);
@@ -367,26 +499,40 @@
                 });
                 this._updateSingleTagsField(tags);
             }
-            // Animate the removal.
+
             if (animate) {
-                tag.fadeOut('fast').hide('blind', {direction: 'horizontal'}, 'fast', function(){
+                tag.addClass('removed'); // Excludes this tag from _tags.
+                var hide_args = this._effectExists('blind') ? ['blind', {direction: 'horizontal'}, 'fast'] : ['fast'];
+
+                var thisTag = this;
+                hide_args.push(function() {
                     tag.remove();
-                }).dequeue();
+                    thisTag._trigger('afterTagRemoved', null, {tag: tag, tagLabel: thisTag.tagLabel(tag)});
+                });
+
+                tag.fadeOut('fast').hide.apply(tag, hide_args).dequeue();
             } else {
                 tag.remove();
+                this._trigger('afterTagRemoved', null, {tag: tag, tagLabel: this.tagLabel(tag)});
             }
+
+        },
+
+        removeTagByLabel: function(tagLabel, animate) {
+            var toRemove = this._findTagByLabel(tagLabel);
+            if (!toRemove) {
+                throw "No such tag exists with the name '" + tagLabel + "'";
+            }
+            this.removeTag(toRemove, animate);
         },
 
         removeAll: function() {
             // Removes all tags.
             var that = this;
-            this.tagList.children('.tagit-choice').each(function(index, tag) {
+            this._tags().each(function(index, tag) {
                 that.removeTag(tag, false);
             });
         }
 
     });
-
-})(jQuery);
-
-
+})(jQuery);
\ No newline at end of file