From 646d6199a644a1e6d65706c400163d00fa310bfe Mon Sep 17 00:00:00 2001 From: Byron Jones Date: Tue, 2 Jun 2015 13:25:33 +0800 Subject: Bug 1146771: implement comment tagging --- .../en/default/bug_modal/activity_stream.html.tmpl | 45 +++- .../template/en/default/bug_modal/header.html.tmpl | 4 + extensions/BugModal/web/bug_modal.css | 32 ++- extensions/BugModal/web/bug_modal.js | 47 +++- extensions/BugModal/web/comments.js | 262 +++++++++++++++++++++ .../ProdCompSearch/web/js/prod_comp_search.js | 6 +- js/field.js | 11 +- js/global.js | 23 ++ 8 files changed, 394 insertions(+), 36 deletions(-) create mode 100644 extensions/BugModal/web/comments.js diff --git a/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl index 2e7b3c87b..11442985c 100644 --- a/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl +++ b/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl @@ -36,8 +36,21 @@ END; %] +[% IF user.can_tag_comments %] + + +[% END %] + [% BLOCK comment_header %] -
+
[%# normal comment header %] @@ -49,6 +62,7 @@ gravatar_only = 1 %] + + + - [% IF comment.tags.size %] - - - - [% END %] + + + + [%# default-collapsed comment header %] diff --git a/extensions/BugModal/template/en/default/bug_modal/header.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/header.html.tmpl index 13c146ed5..a1aee3cfd 100644 --- a/extensions/BugModal/template/en/default/bug_modal/header.html.tmpl +++ b/extensions/BugModal/template/en/default/bug_modal/header.html.tmpl @@ -50,6 +50,7 @@ javascript_urls.push( "extensions/ProdCompSearch/web/js/prod_comp_search.js", "extensions/BugModal/web/bug_modal.js", + "extensions/BugModal/web/comments.js", "extensions/BugModal/web/ZeroClipboard/ZeroClipboard.min.js", "js/field.js", "js/comments.js", @@ -93,6 +94,7 @@ id: [% user.id FILTER none %], login: '[% user.login FILTER js %]', is_insider: [% user.is_insider ? "true" : "false" %], + can_tag: [% user.can_tag_comments ? "true" : "false" %], settings: { quote_replies: '[% user.settings.quote_replies.value FILTER js %]', zoom_textareas: [% user.settings.zoom_textareas.value == "on" ? "true" : "false" %] @@ -101,6 +103,8 @@ [% IF user.id %] BUGZILLA.default_assignee = '[% bug.component_obj.default_assignee.login FILTER js %]'; BUGZILLA.default_qa_contact = '[% bug.component_obj.default_qa_contact.login FILTER js %]'; + BUGZILLA.constant.min_comment_tag_length = [% constants.MIN_COMMENT_TAG_LENGTH FILTER none %]; + BUGZILLA.constant.max_comment_tag_length = [% constants.MAX_COMMENT_TAG_LENGTH FILTER none %]; [% END %] [% END %] diff --git a/extensions/BugModal/web/bug_modal.css b/extensions/BugModal/web/bug_modal.css index afa4719c4..b02fc6067 100644 --- a/extensions/BugModal/web/bug_modal.css +++ b/extensions/BugModal/web/bug_modal.css @@ -545,13 +545,37 @@ td.flag-requestee { } .comment-tag { - border: 1px solid #ccc; - padding: 2px 4px; + border: 1px solid #eee; + padding: 2px 6px 2px 4px; margin-right: 2px; - border-radius: 0.5em; + border-radius: 4px; background-color: #fff; color: #000; - font-size: 12px; +} + +.comment-tag a { + padding-right: 4px; + cursor: pointer; +} + +#ctag { + margin-bottom: 4px; +} + +#ctag button { + margin-top: 2px; +} + +#ctag a { + margin-left: 8px; +} + +#ctag-error { + padding-left: 5px; + background-color: #faa; + color: #444; + border-radius: 2px; + margin-top: 2px; } .comment-collapse-reason { diff --git a/extensions/BugModal/web/bug_modal.js b/extensions/BugModal/web/bug_modal.js index a9ff51452..9d4cd83d0 100644 --- a/extensions/BugModal/web/bug_modal.js +++ b/extensions/BugModal/web/bug_modal.js @@ -111,7 +111,7 @@ $(function() { $('#cc-' + id).hide(); $('#ch-' + id).show(); } - $('#ct-' + id).slideToggle('fast', function() { + $('#ct-' + id + ', #ctag-' + id).slideToggle('fast', function() { $('#c' + id).find('.activity').toggle(); spinner.text($('#ct-' + id + ':visible').length ? '-' : '+'); }); @@ -345,11 +345,7 @@ $(function() { autoSelectFirst: true, formatResult: function(suggestion, currentValue) { // disable wrapping of matched substring - return suggestion.value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + return suggestion.value.htmlEncode(); }, onSelect: function() { this.focus(); @@ -1067,7 +1063,7 @@ function bugzilla_ajax(request, done_fn, error_fn) { request.data = JSON.stringify(request.data); } } - $.ajax(request) + return $.ajax(request) .done(function(data) { if (data.error) { $('#xhr-error').html(data.message); @@ -1080,6 +1076,8 @@ function bugzilla_ajax(request, done_fn, error_fn) { } }) .error(function(data) { + if (data.statusText === 'abort') + return; var message = data.responseJSON ? data.responseJSON.message : 'Unexpected Error'; // all errors are unexpected :) if (!request.hideError) { $('#xhr-error').html(message); @@ -1137,6 +1135,41 @@ function lb_close(event) { $('#lb_overlay, #lb_overlay2, #lb_close_btn, #lb_img, #lb_text').remove(); } +// extensions + +(function($) { + $.extend({ + // Case insensative $.inArray (http://api.jquery.com/jquery.inarray/) + // $.inArrayIn(value, array [, fromIndex]) + // value (type: String) + // The value to search for + // array (type: Array) + // An array through which to search. + // fromIndex (type: Number) + // The index of the array at which to begin the search. + // The default is 0, which will search the whole array. + inArrayIn: function(elem, arr, i) { + // not looking for a string anyways, use default method + if (typeof elem !== 'string') { + return $.inArray.apply(this, arguments); + } + // confirm array is populated + if (arr) { + var len = arr.length; + i = i ? (i < 0 ? Math.max(0, len + i) : i) : 0; + elem = elem.toLowerCase(); + for (; i < len; i++) { + if (i in arr && arr[i].toLowerCase() == elem) { + return i; + } + } + } + // stick with inArray/indexOf and return -1 on no match + return -1; + } + }); +})(jQuery); + // no-ops function initHidingOptionsForIE() {} function showFieldWhen() {} diff --git a/extensions/BugModal/web/comments.js b/extensions/BugModal/web/comments.js new file mode 100644 index 000000000..1f0b18696 --- /dev/null +++ b/extensions/BugModal/web/comments.js @@ -0,0 +1,262 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * This Source Code Form is "Incompatible With Secondary Licenses", as + * defined by the Mozilla Public License, v. 2.0. */ + +$(function() { + 'use strict'; + + // comment collapse/expand + $('.comment-spinner') + .click(function(event) { + event.preventDefault(); + var spinner = $(event.target); + var id = spinner.attr('id').match(/\d+$/)[0]; + // switch to full header for initially collapsed comments + if (spinner.attr('id').match(/^ccs-/)) { + $('#cc-' + id).hide(); + $('#ch-' + id).show(); + } + $('#ct-' + id + ', #ctag-' + id).slideToggle('fast', function() { + $('#c' + id).find('.activity').toggle(); + spinner.text($('#ct-' + id + ':visible').length ? '-' : '+'); + }); + }); + + // + // anything after this point is only executed for logged in users + // + + if (BUGZILLA.user.id === 0) return; + + // comment tagging + + function taggingError(commentNo, message) { + $('#ctag-' + commentNo + ' .comment-tags').append($('#ctag-error')); + $('#ctag-error-message').text(message); + $('#ctag-error').show(); + } + + function deleteTag(event) { + event.preventDefault(); + $('#ctag-error').hide(); + + var that = $(this); + var comment = that.parents('.comment'); + var commentNo = comment.data('no'); + var commentID = comment.data('id'); + var tag = that.parent('.comment-tag').contents().filter(function() { + return this.nodeType === 3; + }).text(); + var container = that.parents('.comment-tags'); + + // update ui + that.parent('.comment-tag').remove(); + renderTags(commentNo, tagsFromDom(container)); + + // update bugzilla + bugzilla_ajax( + { + url: 'rest/bug/comment/' + commentID + '/tags', + type: 'PUT', + data: { remove: [ tag ] }, + hideError: true + }, + function(data) { + renderTags(commentNo, data); + }, + function(message) { + taggingError(commentNo, message); + } + ); + } + $('.comment-tag a').click(deleteTag); + + function tagsFromDom(commentTagsDiv) { + return commentTagsDiv + .find('.comment-tag') + .contents() + .filter(function() { return this.nodeType === 3; }) + .map(function() { return $(this).text(); }) + .toArray(); + } + + function renderTags(commentNo, tags) { + cancelRefresh(); + var root = $('#ctag-' + commentNo + ' .comment-tags'); + root.find('.comment-tag').remove(); + $.each(tags, function() { + var span = $('').addClass('comment-tag').text(this); + if (BUGZILLA.user.can_tag) { + span.prepend($('x').click(deleteTag)); + } + root.append(span); + }); + $('#ctag-' + commentNo + ' .comment-tags').append($('#ctag-error')); + } + + var refreshXHR; + + function refreshTags(commentNo, commentID) { + cancelRefresh(); + refreshXHR = bugzilla_ajax( + { + url: 'rest/bug/comment/' + commentID + '?include_fields=tags', + hideError: true + }, + function(data) { + refreshXHR = false; + renderTags(commentNo, data.comments[commentID].tags); + }, + function(message) { + refreshXHR = false; + taggingError(commentNo, message); + } + ); + } + + function cancelRefresh() { + if (refreshXHR) { + refreshXHR.abort(); + refreshXHR = false; + } + } + + $('#ctag-add') + .devbridgeAutocomplete({ + serviceUrl: function(query) { + return 'rest/bug/comment/tags/' + encodeURIComponent(query); + }, + params: { + Bugzilla_api_token: (BUGZILLA.api_token ? BUGZILLA.api_token : '') + }, + deferRequestBy: 250, + minChars: 3, + tabDisabled: true, + autoSelectFirst: true, + triggerSelectOnValidInput: false, + transformResult: function(response) { + response = $.parseJSON(response); + return { + suggestions: $.map(response, function(tag) { + return { value: tag }; + }) + }; + }, + formatResult: function(suggestion, currentValue) { + // disable wrapping of matched substring + return suggestion.value.htmlEncode(); + } + }) + .keydown(function(event) { + if (event.which === 27) { + event.preventDefault(); + $('#ctag-close').click(); + } + else if (event.which === 13) { + event.preventDefault(); + $('#ctag-error').hide(); + + var ctag = $('#ctag'); + var newTags = $('#ctag-add').val().trim().split(/[ ,]/); + var commentNo = ctag.data('commentNo'); + var commentID = ctag.data('commentID'); + + $('#ctag-close').click(); + + // update ui + var tags = tagsFromDom($(this).parents('.comment-tags')); + var dirty = false; + var addTags = []; + $.each(newTags, function(index, value) { + if ($.inArrayIn(value, tags) == -1) + addTags.push(value); + }); + if (addTags.length === 0) + return; + + // validate + try { + $.each(addTags, function(index, value) { + if (value.length < BUGZILLA.constant.min_comment_tag_length) { + throw 'Comment tags must be at least ' + + BUGZILLA.constant.min_comment_tag_length + ' characters.'; + } + if (value.length > BUGZILLA.constant.max_comment_tag_length) { + throw 'Comment tags cannot be longer than ' + + BUGZILLA.constant.min_comment_tag_length + ' characters.'; + } + }); + } catch(ex) { + taggingError(commentNo, ex); + return; + } + + Array.prototype.push.apply(tags, addTags); + tags.sort(); + renderTags(commentNo, tags); + + // update bugzilla + bugzilla_ajax( + { + url: 'rest/bug/comment/' + commentID + '/tags', + type: 'PUT', + data: { add: addTags }, + hideError: true + }, + function(data) { + renderTags(commentNo, data); + }, + function(message) { + taggingError(commentNo, message); + refreshTags(commentNo, commentID); + } + ); + } + }); + + $('#ctag-close') + .click(function(event) { + event.preventDefault(); + $('#ctag').hide().data('commentNo', ''); + }); + + $('.tag-btn') + .click(function(event) { + event.preventDefault(); + var that = $(this); + var commentNo = that.data('no'); + var commentID = that.data('id'); + var ctag = $('#ctag'); + $('#ctag-error').hide(); + + // toggle -> hide + if (ctag.data('commentNo') === commentNo) { + ctag.hide().data('commentNo', ''); + window.focus(); + return; + } + ctag.data('commentNo', commentNo); + ctag.data('commentID', commentID); + + // kick off a refresh of the tags + refreshTags(commentNo, commentID); + + // expand collapsed comments + if ($('#ct-' + commentNo + ':visible').length === 0) { + $('#cs-' + commentNo + ', #ccs-' + commentNo).click(); + } + + // move, show, and focus tagging ui + ctag.prependTo('#ctag-' + commentNo + ' .comment-tags').show(); + $('#ctag-add').val('').focus(); + }); + + $('.close-btn') + .click(function(event) { + event.preventDefault(); + $('#' + $(this).data('for')).hide(); + }); +}); diff --git a/extensions/ProdCompSearch/web/js/prod_comp_search.js b/extensions/ProdCompSearch/web/js/prod_comp_search.js index 69cc7cc0b..efbaab81b 100644 --- a/extensions/ProdCompSearch/web/js/prod_comp_search.js +++ b/extensions/ProdCompSearch/web/js/prod_comp_search.js @@ -99,11 +99,7 @@ $(function() { }, formatResult: function(suggestion, currentValue) { var value = (suggestion.data.component ? suggestion.data.component : suggestion.data.product); - var escaped = value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + var escaped = value.htmlEncode(); if (suggestion.data.component) { return '- ' + escaped; } diff --git a/js/field.js b/js/field.js index 778451daf..fdacd4728 100644 --- a/js/field.js +++ b/js/field.js @@ -738,10 +738,7 @@ $(function() { formatResult: function(suggestion, currentValue) { return (suggestion.data.name === '' ? suggestion.data.login : suggestion.data.name + ' (' + suggestion.data.login + ')') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + .htmlEncode(); }, onSearchStart: function(params) { var that = $(this); @@ -800,11 +797,7 @@ $(function() { autoSelectFirst: true, formatResult: function(suggestion, currentValue) { // disable wrapping of matched substring - return suggestion.value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + return suggestion.value.htmlEncode(); }, onSelect: function() { this.focus(); diff --git a/js/global.js b/js/global.js index 7675fd98a..7ecd3d901 100644 --- a/js/global.js +++ b/js/global.js @@ -87,3 +87,26 @@ function display_value(field, value) { if (translated) return translated; return value; } + +// polyfill .trim +if (!String.prototype.trim) { + (function() { + // Make sure we trim BOM and NBSP + var rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g; + String.prototype.trim = function() { + return this.replace(rtrim, ''); + }; + })(); +} + +// html encoding +if (!String.prototype.htmlEncode) { + (function() { + String.prototype.htmlEncode = function() { + return this.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }; + })(); +} -- cgit v1.2.3-24-g4f1b