From 7d33443002e5da146e506f92600ff456571ac84a Mon Sep 17 00:00:00 2001 From: Byron Jones Date: Wed, 11 Dec 2013 04:30:11 +0800 Subject: Bug 942725: backport bug 793963 to bmo (add the ability to tag comments with arbitrary tags) --- Bugzilla/Bug.pm | 36 +- Bugzilla/Comment.pm | 195 ++++++++++- Bugzilla/Comment/TagWeights.pm | 74 ++++ Bugzilla/Config/Common.pm | 5 + Bugzilla/Config/GroupSecurity.pm | 1 + Bugzilla/Constants.pm | 7 + Bugzilla/Field.pm | 1 + Bugzilla/User.pm | 17 + Bugzilla/WebService/Bug.pm | 80 ++++- Bugzilla/WebService/Constants.pm | 7 +- Bugzilla/WebService/Server/REST/Resources/Bug.pm | 16 + js/comment-tagging.js | 385 +++++++++++++++++++++ js/comments.js | 19 +- js/util.js | 25 +- show_activity.cgi | 4 +- skins/contrib/Mozilla/global.css | 29 ++ skins/standard/show_bug.css | 39 +++ .../en/default/admin/params/bugfields.html.tmpl | 6 +- .../default/admin/params/groupsecurity.html.tmpl | 3 + template/en/default/bug/comments.html.tmpl | 54 ++- template/en/default/bug/edit.html.tmpl | 25 ++ template/en/default/bug/show-header.html.tmpl | 3 + template/en/default/filterexceptions.pl | 1 + template/en/default/global/user-error.html.tmpl | 23 +- 24 files changed, 1010 insertions(+), 45 deletions(-) create mode 100644 Bugzilla/Comment/TagWeights.pm create mode 100644 js/comment-tagging.js diff --git a/Bugzilla/Bug.pm b/Bugzilla/Bug.pm index 1c3b0267c..51035ce5a 100644 --- a/Bugzilla/Bug.pm +++ b/Bugzilla/Bug.pm @@ -3458,7 +3458,8 @@ sub comments { $comment->{count} = $count++; $comment->{bug} = $self; } - Bugzilla::Comment->preload($self->{'comments'}); + # Some bugs may have no comments when upgrading old installations. + Bugzilla::Comment->preload($self->{'comments'}) if $count; } return unless defined wantarray; @@ -3879,7 +3880,7 @@ sub _bugs_in_order { # Get the activity of a bug, starting from $starttime (if given). # This routine assumes Bugzilla::Bug->check has been previously called. sub GetBugActivity { - my ($bug_id, $attach_id, $starttime) = @_; + my ($bug_id, $attach_id, $starttime, $include_comment_tags) = @_; my $dbh = Bugzilla->dbh; # Arguments passed to the SQL query. @@ -3890,7 +3891,7 @@ sub GetBugActivity { if (defined $starttime) { trick_taint($starttime); push (@args, $starttime); - $datepart = "AND bugs_activity.bug_when > ?"; + $datepart = "AND bug_when > ?"; } my $attachpart = ""; @@ -3911,7 +3912,7 @@ sub GetBugActivity { my $query = "SELECT fielddefs.name, bugs_activity.attach_id, " . $dbh->sql_date_format('bugs_activity.bug_when', '%Y.%m.%d %H:%i:%s') . - ", bugs_activity.removed, bugs_activity.added, profiles.login_name, + " AS bug_when, bugs_activity.removed, bugs_activity.added, profiles.login_name, bugs_activity.comment_id FROM bugs_activity $suppjoins @@ -3922,8 +3923,31 @@ sub GetBugActivity { WHERE bugs_activity.bug_id = ? $datepart $attachpart - $suppwhere - ORDER BY bugs_activity.bug_when, bugs_activity.id"; + $suppwhere "; + + if (Bugzilla->params->{'comment_taggers_group'} + && $include_comment_tags + && !$attach_id) + { + $query .= " + UNION ALL + SELECT 'comment_tag' AS name, + NULL AS attach_id," . + $dbh->sql_date_format('longdescs_tags_activity.bug_when', '%Y.%m.%d %H:%i:%s') . " AS bug_when, + longdescs_tags_activity.removed, + longdescs_tags_activity.added, + profiles.login_name, + longdescs_tags_activity.comment_id as comment_id + FROM longdescs_tags_activity + INNER JOIN profiles ON profiles.userid = longdescs_tags_activity.who + WHERE longdescs_tags_activity.bug_id = ? + $datepart + "; + push @args, $bug_id; + push @args, $starttime if defined $starttime; + } + + $query .= "ORDER BY bug_when, comment_id"; my $list = $dbh->selectall_arrayref($query, undef, @args); diff --git a/Bugzilla/Comment.pm b/Bugzilla/Comment.pm index 4559cf1fd..623796142 100644 --- a/Bugzilla/Comment.pm +++ b/Bugzilla/Comment.pm @@ -26,11 +26,13 @@ package Bugzilla::Comment; use base qw(Bugzilla::Object); use Bugzilla::Attachment; +use Bugzilla::Comment::TagWeights; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::User; use Bugzilla::Util; +use List::Util qw(first); use Scalar::Util qw(blessed); ############################### @@ -92,21 +94,90 @@ use constant VALIDATOR_DEPENDENCIES => { sub update { my $self = shift; - my $changes = $self->SUPER::update(@_); - $self->bug->_sync_fulltext( update_comments => 1); + my ($changes, $old_comment) = $self->SUPER::update(@_); + + if (exists $changes->{'thetext'} || exists $changes->{'isprivate'}) { + $self->bug->_sync_fulltext( update_comments => 1); + } + + my @old_tags = @{ $old_comment->tags }; + my @new_tags = @{ $self->tags }; + my ($removed_tags, $added_tags) = diff_arrays(\@old_tags, \@new_tags); + + if (@$removed_tags || @$added_tags) { + my $dbh = Bugzilla->dbh; + my $when = $dbh->selectrow_array("SELECT LOCALTIMESTAMP(0)"); + my $sth_delete = $dbh->prepare( + "DELETE FROM longdescs_tags WHERE comment_id = ? AND tag = ?" + ); + my $sth_insert = $dbh->prepare( + "INSERT INTO longdescs_tags(comment_id, tag) VALUES (?, ?)" + ); + my $sth_activity = $dbh->prepare( + "INSERT INTO longdescs_tags_activity + (bug_id, comment_id, who, bug_when, added, removed) + VALUES (?, ?, ?, ?, ?, ?)" + ); + + foreach my $tag (@$removed_tags) { + my $weighted = Bugzilla::Comment::TagWeights->new({ name => $tag }); + if ($weighted) { + if ($weighted->weight == 1) { + $weighted->remove_from_db(); + } else { + $weighted->set_weight($weighted->weight - 1); + $weighted->update(); + } + } + trick_taint($tag); + $sth_delete->execute($self->id, $tag); + $sth_activity->execute( + $self->bug_id, $self->id, Bugzilla->user->id, $when, '', $tag); + } + + foreach my $tag (@$added_tags) { + my $weighted = Bugzilla::Comment::TagWeights->new({ name => $tag }); + if ($weighted) { + $weighted->set_weight($weighted->weight + 1); + $weighted->update(); + } else { + Bugzilla::Comment::TagWeights->create({ tag => $tag, weight => 1 }); + } + trick_taint($tag); + $sth_insert->execute($self->id, $tag); + $sth_activity->execute( + $self->bug_id, $self->id, Bugzilla->user->id, $when, $tag, ''); + } + } + return $changes; } -# Speeds up displays of comment lists by loading all ->author objects -# at once for a whole list. +# Speeds up displays of comment lists by loading all author objects and tags at +# once for a whole list. sub preload { my ($class, $comments) = @_; + # Author my %user_ids = map { $_->{who} => 1 } @$comments; my $users = Bugzilla::User->new_from_list([keys %user_ids]); my %user_map = map { $_->id => $_ } @$users; foreach my $comment (@$comments) { $comment->{author} = $user_map{$comment->{who}}; } + # Tags + if (Bugzilla->params->{'comment_taggers_group'}) { + my $dbh = Bugzilla->dbh; + my @comment_ids = map { $_->id } @$comments; + my %comment_map = map { $_->id => $_ } @$comments; + my $rows = $dbh->selectall_arrayref( + "SELECT comment_id, " . $dbh->sql_group_concat('tag', "','") . " + FROM longdescs_tags + WHERE " . $dbh->sql_in('comment_id', \@comment_ids) . " + GROUP BY comment_id"); + foreach my $row (@$rows) { + $comment_map{$row->[0]}->{tags} = [ split(/,/, $row->[1]) ]; + } + } } ############################### @@ -126,6 +197,39 @@ sub work_time { sub type { return $_[0]->{'type'}; } sub extra_data { return $_[0]->{'extra_data'} } +sub tags { + my ($self) = @_; + return [] unless Bugzilla->params->{'comment_taggers_group'}; + $self->{'tags'} ||= Bugzilla->dbh->selectcol_arrayref( + "SELECT tag + FROM longdescs_tags + WHERE comment_id = ? + ORDER BY tag", + undef, $self->id); + return $self->{'tags'}; +} + +sub collapsed { + my ($self) = @_; + return 0 unless Bugzilla->params->{'comment_taggers_group'}; + return $self->{collapsed} if exists $self->{collapsed}; + $self->{collapsed} = 0; + Bugzilla->request_cache->{comment_tags_collapsed} + ||= [ split(/\s*,\s*/, Bugzilla->params->{'collapsed_comment_tags'}) ]; + my @collapsed_tags = @{ Bugzilla->request_cache->{comment_tags_collapsed} }; + foreach my $my_tag (@{ $self->tags }) { + $my_tag = lc($my_tag); + foreach my $collapsed_tag (@collapsed_tags) { + if ($my_tag eq lc($collapsed_tag)) { + $self->{collapsed} = 1; + last; + } + } + last if $self->{collapsed}; + } + return $self->{collapsed}; +} + sub bug { my $self = shift; require Bugzilla::Bug; @@ -182,6 +286,26 @@ sub set_is_private { $_[0]->set('isprivate', $_[1]); } sub set_type { $_[0]->set('type', $_[1]); } sub set_extra_data { $_[0]->set('extra_data', $_[1]); } +sub add_tag { + my ($self, $tag) = @_; + $tag = $self->_check_tag($tag); + + my $tags = $self->tags; + return if grep { lc($tag) eq lc($_) } @$tags; + push @$tags, $tag; + $self->{'tags'} = [ sort @$tags ]; +} + +sub remove_tag { + my ($self, $tag) = @_; + $tag = $self->_check_tag($tag); + + my $tags = $self->tags; + my $index = first { lc($tags->[$_]) eq lc($tag) } 0..scalar(@$tags) - 1; + return unless defined $index; + splice(@$tags, $index, 1); +} + ############## # Validators # ############## @@ -324,6 +448,17 @@ sub _check_isprivate { return $isprivate ? 1 : 0; } +sub _check_tag { + my ($invocant, $tag) = @_; + length($tag) < MIN_COMMENT_TAG_LENGTH + and ThrowUserError('comment_tag_too_short', { tag => $tag }); + length($tag) > MAX_COMMENT_TAG_LENGTH + and ThrowUserError('comment_tag_too_long', { tag => $tag }); + $tag =~ /^[\w\d\._-]+$/ + or ThrowUserError('comment_tag_invalid', { tag => $tag }); + return $tag; +} + sub count { my ($self) = @_; @@ -338,7 +473,7 @@ sub count { undef, $self->bug_id, $self->creation_ts); return --$self->{'count'}; -} +} 1; @@ -384,7 +519,7 @@ C Time spent as related to this comment. =item C -C Comment is marked as private +C Comment is marked as private. =item C @@ -399,6 +534,54 @@ L who created the comment. C The position this comment is located in the full list of comments for a bug starting from 0. +=item C + +C Comment should be displayed as collapsed by default. + +=item C + +C The tags attached to the comment. + +=item C + +=over + +=item B + +Attaches the specified tag to the comment. + +=item B + +=over + +=item C + +C The tag to attach. + +=back + +=back + +=item C + +=over + +=item B + +Detaches the specified tag from the comment. + +=item B + +=over + +=item C + +C The tag to detach. + +=back + +=back + =item C =over diff --git a/Bugzilla/Comment/TagWeights.pm b/Bugzilla/Comment/TagWeights.pm new file mode 100644 index 000000000..5835efbc4 --- /dev/null +++ b/Bugzilla/Comment/TagWeights.pm @@ -0,0 +1,74 @@ +# 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. + +package Bugzilla::Comment::TagWeights; + +use 5.10.1; +use strict; + +use parent qw(Bugzilla::Object); + +use Bugzilla::Constants; + +# No auditing required +use constant AUDIT_CREATES => 0; +use constant AUDIT_UPDATES => 0; +use constant AUDIT_REMOVES => 0; + +use constant DB_COLUMNS => qw( + id + tag + weight +); + +use constant UPDATE_COLUMNS => qw( + weight +); + +use constant DB_TABLE => 'longdescs_tags_weights'; +use constant ID_FIELD => 'id'; +use constant NAME_FIELD => 'tag'; +use constant LIST_ORDER => 'weight DESC'; +use constant VALIDATORS => { }; + +sub tag { return $_[0]->{'tag'} } +sub weight { return $_[0]->{'weight'} } + +sub set_weight { $_[0]->set('weight', $_[1]); } + +1; + +=head1 NAME + +Comment::TagWeights - Bugzilla comment weighting class. + +=head1 DESCRIPTION + +TagWeights.pm represents a Comment::TagWeight object. It is an implementation +of L, and thus provides all methods that L +provides. + +TagWeights is used to quickly find tags and order by their usage count. + +=head1 PROPERTIES + +=over + +=item C + +C The tag + +=item C + +C The tag's weight. The value returned corresponds to the number of +comments with this tag attached. + +=item C + +C Set the tag's weight. + +=back diff --git a/Bugzilla/Config/Common.pm b/Bugzilla/Config/Common.pm index efbd1a139..edd5872e1 100644 --- a/Bugzilla/Config/Common.pm +++ b/Bugzilla/Config/Common.pm @@ -476,4 +476,9 @@ Checks that the value is a valid number Checks that the value is a valid regexp +=item C + +Checks that the required modules for comment tagging are installed, and that a +valid group is provided. + =back diff --git a/Bugzilla/Config/GroupSecurity.pm b/Bugzilla/Config/GroupSecurity.pm index 90932c736..24eddaf6b 100644 --- a/Bugzilla/Config/GroupSecurity.pm +++ b/Bugzilla/Config/GroupSecurity.pm @@ -115,4 +115,5 @@ sub _get_all_group_names { unshift(@group_names, ''); return \@group_names; } + 1; diff --git a/Bugzilla/Constants.pm b/Bugzilla/Constants.pm index b4262f8ab..b7038505a 100644 --- a/Bugzilla/Constants.pm +++ b/Bugzilla/Constants.pm @@ -90,6 +90,9 @@ use Memoize; COMMENT_COLS MAX_COMMENT_LENGTH + MIN_COMMENT_TAG_LENGTH + MAX_COMMENT_TAG_LENGTH + CMT_NORMAL CMT_DUPE_OF CMT_HAS_DUPE @@ -311,6 +314,10 @@ use constant COMMENT_COLS => 80; # Used in _check_comment(). Gives the max length allowed for a comment. use constant MAX_COMMENT_LENGTH => 65535; +# The minimum and maximum length of comment tags. +use constant MIN_COMMENT_TAG_LENGTH => 3; +use constant MAX_COMMENT_TAG_LENGTH => 24; + # The type of bug comments. use constant CMT_NORMAL => 0; use constant CMT_DUPE_OF => 1; diff --git a/Bugzilla/Field.pm b/Bugzilla/Field.pm index f1118da6a..5cd246b8e 100644 --- a/Bugzilla/Field.pm +++ b/Bugzilla/Field.pm @@ -264,6 +264,7 @@ use constant DEFAULT_FIELDS => ( {name => 'see_also', desc => "See Also", type => FIELD_TYPE_BUG_URLS}, {name => 'tag', desc => 'Tags'}, + {name => 'comment_tag', desc => 'Comment Tag'}, ); ################ diff --git a/Bugzilla/User.pm b/Bugzilla/User.pm index 4e4489935..727bb9f2c 100644 --- a/Bugzilla/User.pm +++ b/Bugzilla/User.pm @@ -1876,6 +1876,17 @@ sub is_timetracker { return $self->{'is_timetracker'}; } +sub can_tag_comments { + my $self = shift; + + if (!defined $self->{'can_tag_comments'}) { + my $group = Bugzilla->params->{'comment_taggers_group'}; + $self->{'can_tag_comments'} = + ($group && $self->in_group($group)) ? 1 : 0; + } + return $self->{'can_tag_comments'}; +} + sub get_userlist { my $self = shift; @@ -2666,6 +2677,12 @@ i.e. if the 'insidergroup' parameter is set and the user belongs to this group. Returns true if the user is a global watcher, i.e. if the 'globalwatchers' parameter contains the user. +=item C + +Returns true if the user can attach tags to comments. +i.e. if the 'comment_taggers_group' parameter is set and the user belongs to +this group. + =back =head1 CLASS FUNCTIONS diff --git a/Bugzilla/WebService/Bug.pm b/Bugzilla/WebService/Bug.pm index 707e0d42d..3efe6aa1f 100644 --- a/Bugzilla/WebService/Bug.pm +++ b/Bugzilla/WebService/Bug.pm @@ -26,6 +26,7 @@ use strict; use base qw(Bugzilla::WebService); use Bugzilla::Comment; +use Bugzilla::Comment::TagWeights; use Bugzilla::Constants; use Bugzilla::Error; use Bugzilla::Field; @@ -33,7 +34,7 @@ use Bugzilla::WebService::Constants; use Bugzilla::WebService::Util qw(filter filter_wants validate translate); use Bugzilla::Bug; use Bugzilla::BugMail; -use Bugzilla::Util qw(trick_taint trim); +use Bugzilla::Util qw(trick_taint trim detaint_natural); use Bugzilla::Version; use Bugzilla::Milestone; use Bugzilla::Status; @@ -327,7 +328,8 @@ sub _translate_comment { my ($self, $comment, $filters) = @_; my $attach_id = $comment->is_about_attachment ? $comment->extra_data : undef; - return filter $filters, { + + my $comment_hash = { id => $self->type('int', $comment->id), bug_id => $self->type('int', $comment->bug_id), creator => $self->type('email', $comment->author->login), @@ -338,6 +340,16 @@ sub _translate_comment { text => $self->type('string', $comment->body_full), attachment_id => $self->type('int', $attach_id), }; + + # Don't load comment tags unless enabled + if (Bugzilla->params->{'comment_taggers_group'}) { + $comment_hash->{tags} = [ + map { $self->type('string', $_) } + @{ $comment->tags } + ]; + } + + return filter $filters, $comment_hash; } sub get { @@ -1038,6 +1050,70 @@ sub attachments { return { bugs => \%bugs, attachments => \%attachments }; } +sub update_comment_tags { + my ($self, $params) = @_; + + my $user = Bugzilla->login(LOGIN_REQUIRED); + Bugzilla->params->{'comment_taggers_group'} + || ThrowUserError("comment_tag_disabled"); + $user->can_tag_comments + || ThrowUserError("auth_failure", + { group => Bugzilla->params->{'comment_taggers_group'}, + action => "update", + object => "comment_tags" }); + + my $comment_id = $params->{comment_id} + // ThrowCodeError('param_required', + { function => 'Bug.update_comment_tags', + param => 'comment_id' }); + + my $comment = Bugzilla::Comment->new($comment_id) + || return []; + $comment->bug->check_is_visible(); + if ($comment->is_private && !$user->is_insider) { + ThrowUserError('comment_is_private', { id => $comment_id }); + } + + my $dbh = Bugzilla->dbh; + $dbh->bz_start_transaction(); + foreach my $tag (@{ $params->{add} || [] }) { + $comment->add_tag($tag) if defined $tag; + } + foreach my $tag (@{ $params->{remove} || [] }) { + $comment->remove_tag($tag) if defined $tag; + } + $comment->update(); + $dbh->bz_commit_transaction(); + + return $comment->tags; +} + +sub search_comment_tags { + my ($self, $params) = @_; + + Bugzilla->login(LOGIN_REQUIRED); + Bugzilla->params->{'comment_taggers_group'} + || ThrowUserError("comment_tag_disabled"); + Bugzilla->user->can_tag_comments + || ThrowUserError("auth_failure", { group => Bugzilla->params->{'comment_taggers_group'}, + action => "search", + object => "comment_tags"}); + + my $query = $params->{query}; + $query + // ThrowCodeError('param_required', { param => 'query' }); + my $limit = detaint_natural($params->{limit}) || 7; + + my $tags = Bugzilla::Comment::TagWeights->match({ + WHERE => { + 'tag LIKE ?' => "\%$query\%", + }, + LIMIT => $limit, + }); + return [ map { $_->tag } @$tags ]; +} + + ############################## # Private Helper Subroutines # ############################## diff --git a/Bugzilla/WebService/Constants.pm b/Bugzilla/WebService/Constants.pm index 0aa6c1bbd..87d890176 100644 --- a/Bugzilla/WebService/Constants.pm +++ b/Bugzilla/WebService/Constants.pm @@ -106,7 +106,12 @@ use constant WS_ERROR_CODE => { comment_is_private => 110, comment_id_invalid => 111, comment_too_long => 114, - comment_invalid_isprivate => 117, + comment_invalid_isprivate => 117, + # Comment tagging + comment_tag_disabled => 125, + comment_tag_invalid => 126, + comment_tag_too_long => 127, + comment_tag_too_short => 128, # See Also errors bug_url_invalid => 112, bug_url_too_long => 112, diff --git a/Bugzilla/WebService/Server/REST/Resources/Bug.pm b/Bugzilla/WebService/Server/REST/Resources/Bug.pm index 98ae6049c..ea420b4ed 100644 --- a/Bugzilla/WebService/Server/REST/Resources/Bug.pm +++ b/Bugzilla/WebService/Server/REST/Resources/Bug.pm @@ -65,6 +65,22 @@ sub _rest_resources { } } }, + qr{^/bug/comment/tags/([^/]+)$}, { + GET => { + method => 'search_comment_tags', + params => sub { + return { query => $_[0] }; + }, + }, + }, + qr{^/bug/comment/([^/]+)/tags$}, { + PUT => { + method => 'update_comment_tags', + params => sub { + return { comment_id => $_[0] }; + }, + }, + }, qr{^/bug/([^/]+)/history$}, { GET => { method => 'history', diff --git a/js/comment-tagging.js b/js/comment-tagging.js new file mode 100644 index 000000000..94afbb61a --- /dev/null +++ b/js/comment-tagging.js @@ -0,0 +1,385 @@ +/* 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. */ + +var Dom = YAHOO.util.Dom; + +YAHOO.bugzilla.commentTagging = { + ctag_div : false, + ctag_add : false, + counter : 0, + min_len : 3, + max_len : 24, + tags_by_no: {}, + nos_by_tag: {}, + current_id: 0, + current_no: -1, + can_edit : false, + pending : {}, + + init : function(can_edit) { + this.can_edit = can_edit; + this.ctag_div = Dom.get('bz_ctag_div'); + this.ctag_add = Dom.get('bz_ctag_add'); + YAHOO.util.Event.on(this.ctag_add, 'keypress', this.onKeyPress); + YAHOO.util.Event.onDOMReady(function() { + YAHOO.bugzilla.commentTagging.updateCollapseControls(); + }); + if (!can_edit) return; + + var ds = new YAHOO.util.XHRDataSource("jsonrpc.cgi"); + ds.connTimeout = 30000; + ds.connMethodPost = true; + ds.connXhrMode = "cancelStaleRequests"; + ds.maxCacheEntries = 5; + ds.responseSchema = { + metaFields : { error: "error", jsonRpcId: "id"}, + resultsList : "result" + }; + + var ac = new YAHOO.widget.AutoComplete('bz_ctag_add', 'bz_ctag_autocomp', ds); + ac.maxResultsDisplayed = 7; + ac.generateRequest = function(query) { + query = YAHOO.lang.trim(query); + YAHOO.bugzilla.commentTagging.last_query = query; + YAHOO.bugzilla.commentTagging.counter = YAHOO.bugzilla.commentTagging.counter + 1; + YAHOO.util.Connect.setDefaultPostHeader('application/json', true); + return YAHOO.lang.JSON.stringify({ + method : "Bug.search_comment_tags", + id : YAHOO.bugzilla.commentTagging.counter, + params : [ { query : query, limit : 10 } ] + }); + }; + ac.minQueryLength = this.min_len; + ac.autoHighlight = false; + ac.typeAhead = true; + ac.queryDelay = 0.5; + ac.dataReturnEvent.subscribe(function(type, args) { + args[0].autoHighlight = args[2].length == 1; + }); + }, + + toggle : function(comment_id, comment_no) { + if (!this.ctag_div) return; + var tags_container = Dom.get('ct_' + comment_no); + + if (this.current_id == comment_id) { + // hide + this.current_id = 0; + this.current_no = -1; + Dom.addClass(this.ctag_div, 'bz_default_hidden'); + this.hideError(); + window.focus(); + + } else { + // show or move + this.rpcRefresh(comment_id, comment_no); + this.current_id = comment_id; + this.current_no = comment_no; + this.ctag_add.value = ''; + tags_container.parentNode.insertBefore(this.ctag_div, tags_container); + Dom.removeClass(this.ctag_div, 'bz_default_hidden'); + Dom.removeClass(tags_container.parentNode, 'bz_default_hidden'); + var comment = Dom.get('comment_text_' + comment_no); + if (Dom.hasClass(comment, 'collapsed')) { + var link = Dom.get('comment_link_' + comment_no); + expand_comment(link, comment, comment_no); + } + window.setTimeout(function() { + YAHOO.bugzilla.commentTagging.ctag_add.focus(); + }, 50); + } + }, + + hideInput : function() { + if (this.current_id != 0) { + var comment_no = this.current_no; + this.toggle(this.current_id, this.current_no); + this.hideEmpty(comment_no); + } + this.hideError(); + }, + + hideEmpty : function(comment_no) { + if (Dom.get('ct_' + comment_no).children.length == 0) { + Dom.addClass('comment_tag_' + comment_no, 'bz_default_hidden'); + } + }, + + showError : function(comment_id, comment_no, error) { + var bz_ctag_error = Dom.get('bz_ctag_error'); + var tags_container = Dom.get('ct_' + comment_no); + tags_container.parentNode.appendChild(bz_ctag_error, tags_container); + Dom.get('bz_ctag_error_msg').innerHTML = YAHOO.lang.escapeHTML(error); + Dom.removeClass(bz_ctag_error, 'bz_default_hidden'); + }, + + hideError : function() { + Dom.addClass('bz_ctag_error', 'bz_default_hidden'); + }, + + onKeyPress : function(evt) { + evt = evt || window.event; + var charCode = evt.charCode || evt.keyCode; + if (evt.keyCode == 27) { + // escape + YAHOO.bugzilla.commentTagging.hideInput(); + YAHOO.util.Event.stopEvent(evt); + + } else if (evt.keyCode == 13) { + // return + YAHOO.util.Event.stopEvent(evt); + var tags = YAHOO.bugzilla.commentTagging.ctag_add.value.split(/[ ,]/); + var comment_id = YAHOO.bugzilla.commentTagging.current_id; + var comment_no = YAHOO.bugzilla.commentTagging.current_no; + try { + YAHOO.bugzilla.commentTagging.add(comment_id, comment_no, tags); + YAHOO.bugzilla.commentTagging.hideInput(); + } catch(e) { + YAHOO.bugzilla.commentTagging.showError(comment_id, comment_no, e.message); + } + } + }, + + showTags : function(comment_id, comment_no, tags) { + // remove existing tags + var tags_container = Dom.get('ct_' + comment_no); + while (tags_container.hasChildNodes()) { + tags_container.removeChild(tags_container.lastChild); + } + // add tags + if (tags != '') { + if (typeof(tags) == 'string') { + tags = tags.split(','); + } + for (var i = 0, l = tags.length; i < l; i++) { + tags_container.appendChild(this.buildTagHtml(comment_id, comment_no, tags[i])); + } + } + // update tracking array + this.tags_by_no['c' + comment_no] = tags; + this.updateCollapseControls(); + }, + + updateCollapseControls : function() { + var container = Dom.get('comment_tags_collapse_expand_container'); + if (!container) return; + // build list of tags + this.nos_by_tag = {}; + for (var id in this.tags_by_no) { + if (this.tags_by_no.hasOwnProperty(id)) { + for (var i = 0, l = this.tags_by_no[id].length; i < l; i++) { + var tag = this.tags_by_no[id][i].toLowerCase(); + if (!this.nos_by_tag.hasOwnProperty(tag)) { + this.nos_by_tag[tag] = []; + } + this.nos_by_tag[tag].push(id); + } + } + } + var tags = []; + for (var tag in this.nos_by_tag) { + if (this.nos_by_tag.hasOwnProperty(tag)) { + tags.push(tag); + } + } + tags.sort(); + if (tags.length) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode('Comment Tags:')); + var ul = document.createElement('ul'); + ul.id = 'comment_tags_collapse_expand'; + div.appendChild(ul); + for (var i = 0, l = tags.length; i < l; i++) { + var tag = tags[i]; + var li = document.createElement('li'); + ul.appendChild(li); + var a = document.createElement('a'); + li.appendChild(a); + Dom.setAttribute(a, 'href', '#'); + YAHOO.util.Event.addListener(a, 'click', function(evt, tag) { + YAHOO.bugzilla.commentTagging.toggleCollapse(tag); + YAHOO.util.Event.stopEvent(evt); + }, tag); + li.appendChild(document.createTextNode(' (' + this.nos_by_tag[tag].length + ')')); + a.innerHTML = tag; + } + while (container.hasChildNodes()) { + container.removeChild(container.lastChild); + } + container.appendChild(div); + } else { + while (container.hasChildNodes()) { + container.removeChild(container.lastChild); + } + } + }, + + toggleCollapse : function(tag) { + var nos = this.nos_by_tag[tag]; + if (!nos) return; + toggle_all_comments('collapse'); + for (var i = 0, l = nos.length; i < l; i++) { + var comment_no = nos[i].match(/\d+$/)[0]; + var comment = Dom.get('comment_text_' + comment_no); + var link = Dom.get('comment_link_' + comment_no); + expand_comment(link, comment, comment_no); + } + }, + + buildTagHtml : function(comment_id, comment_no, tag) { + var el = document.createElement('span'); + Dom.setAttribute(el, 'id', 'ct_' + comment_no + '_' + tag); + Dom.addClass(el, 'bz_comment_tag'); + if (this.can_edit) { + var a = document.createElement('a'); + Dom.setAttribute(a, 'href', '#'); + YAHOO.util.Event.addListener(a, 'click', function(evt, args) { + YAHOO.bugzilla.commentTagging.remove(args[0], args[1], args[2]) + YAHOO.util.Event.stopEvent(evt); + }, [comment_id, comment_no, tag]); + a.appendChild(document.createTextNode('x')); + el.appendChild(a); + el.appendChild(document.createTextNode("\u00a0")); + } + el.appendChild(document.createTextNode(tag)); + return el; + }, + + add : function(comment_id, comment_no, add_tags) { + // build list of current tags from html + var tags = new Array(); + var spans = Dom.getElementsByClassName('bz_comment_tag', undefined, 'ct_' + comment_no); + for (var i = 0, l = spans.length; i < l; i++) { + tags.push(spans[i].textContent.substr(2)); + } + // add new tags + var new_tags = new Array(); + for (var i = 0, l = add_tags.length; i < l; i++) { + var tag = YAHOO.lang.trim(add_tags[i]); + // validation + if (tag == '') + continue; + if (tag.length < YAHOO.bugzilla.commentTagging.min_len) + throw new Error("Comment tags must be at least " + this.min_len + " characters."); + if (tag.length > YAHOO.bugzilla.commentTagging.max_len) + throw new Error("Comment tags cannot be longer than " + this.min_len + " characters."); + // append new tag + if (bz_isValueInArrayIgnoreCase(tags, tag)) + continue; + new_tags.push(tag); + tags.push(tag); + } + tags.sort(); + // update + this.showTags(comment_id, comment_no, tags); + this.rpcUpdate(comment_id, comment_no, new_tags, undefined); + }, + + remove : function(comment_id, comment_no, tag) { + var el = Dom.get('ct_' + comment_no + '_' + tag); + if (el) { + el.parentNode.removeChild(el); + this.rpcUpdate(comment_id, comment_no, undefined, [ tag ]); + this.hideEmpty(comment_no); + } + }, + + // If multiple updates are triggered quickly, overlapping refresh events + // are generated. We ignore all events except the last one. + incPending : function(comment_id) { + if (this.pending['c' + comment_id] == undefined) { + this.pending['c' + comment_id] = 1; + } else { + this.pending['c' + comment_id]++; + } + }, + + decPending : function(comment_id) { + if (this.pending['c' + comment_id] != undefined) + this.pending['c' + comment_id]--; + }, + + hasPending : function(comment_id) { + return this.pending['c' + comment_id] != undefined + && this.pending['c' + comment_id] > 0; + }, + + rpcRefresh : function(comment_id, comment_no, noRefreshOnError) { + this.incPending(comment_id); + YAHOO.util.Connect.setDefaultPostHeader('application/json', true); + YAHOO.util.Connect.asyncRequest('POST', 'jsonrpc.cgi', + { + success: function(res) { + YAHOO.bugzilla.commentTagging.decPending(comment_id); + data = YAHOO.lang.JSON.parse(res.responseText); + if (data.error) { + YAHOO.bugzilla.commentTagging.handleRpcError( + comment_id, comment_no, data.error.message, noRefreshOnError); + return; + } + + if (!YAHOO.bugzilla.commentTagging.hasPending(comment_id)) + YAHOO.bugzilla.commentTagging.showTags( + comment_id, comment_no, data.result.comments[comment_id].tags); + }, + failure: function(res) { + YAHOO.bugzilla.commentTagging.decPending(comment_id); + YAHOO.bugzilla.commentTagging.handleRpcError( + comment_id, comment_no, res.responseText, noRefreshOnError); + } + }, + YAHOO.lang.JSON.stringify({ + version: "1.1", + method: 'Bug.comments', + params: { + comment_ids: [ comment_id ], + include_fields: [ 'tags' ] + } + }) + ); + }, + + rpcUpdate : function(comment_id, comment_no, add, remove) { + this.incPending(comment_id); + YAHOO.util.Connect.setDefaultPostHeader('application/json', true); + YAHOO.util.Connect.asyncRequest('POST', 'jsonrpc.cgi', + { + success: function(res) { + YAHOO.bugzilla.commentTagging.decPending(comment_id); + data = YAHOO.lang.JSON.parse(res.responseText); + if (data.error) { + YAHOO.bugzilla.commentTagging.handleRpcError(comment_id, comment_no, data.error.message); + return; + } + + if (!YAHOO.bugzilla.commentTagging.hasPending(comment_id)) + YAHOO.bugzilla.commentTagging.showTags(comment_id, comment_no, data.result); + }, + failure: function(res) { + YAHOO.bugzilla.commentTagging.decPending(comment_id); + YAHOO.bugzilla.commentTagging.handleRpcError(comment_id, comment_no, res.responseText); + } + }, + YAHOO.lang.JSON.stringify({ + version: "1.1", + method: 'Bug.update_comment_tags', + params: { + comment_id: comment_id, + add: add, + remove: remove + } + }) + ); + }, + + handleRpcError : function(comment_id, comment_no, message, noRefreshOnError) { + YAHOO.bugzilla.commentTagging.showError(comment_id, comment_no, message); + if (!noRefreshOnError) { + YAHOO.bugzilla.commentTagging.rpcRefresh(comment_id, comment_no, true); + } + } +} diff --git a/js/comments.js b/js/comments.js index e9a3e209f..2264375eb 100644 --- a/js/comments.js +++ b/js/comments.js @@ -40,9 +40,9 @@ function toggle_comment_display(link, comment_id) { var comment = document.getElementById('comment_text_' + comment_id); var re = new RegExp(/\bcollapsed\b/); if (comment.className.match(re)) - expand_comment(link, comment); + expand_comment(link, comment, comment_id); else - collapse_comment(link, comment); + collapse_comment(link, comment, comment_id); } function toggle_all_comments(action) { @@ -59,20 +59,22 @@ function toggle_all_comments(action) { var id = comments[i].id.match(/\d*$/); var link = document.getElementById('comment_link_' + id); if (action == 'collapse') - collapse_comment(link, comment); + collapse_comment(link, comment, id); else - expand_comment(link, comment); + expand_comment(link, comment, id); } } -function collapse_comment(link, comment) { +function collapse_comment(link, comment, comment_id) { link.innerHTML = "[+]"; YAHOO.util.Dom.addClass(comment, 'collapsed'); + YAHOO.util.Dom.addClass('comment_tag_' + comment_id, 'collapsed'); } -function expand_comment(link, comment) { +function expand_comment(link, comment, comment_id) { link.innerHTML = "[-]"; YAHOO.util.Dom.removeClass(comment, 'collapsed'); + YAHOO.util.Dom.removeClass('comment_tag_' + comment_id, 'collapsed'); } function wrapReplyText(text) { @@ -125,11 +127,12 @@ function wrapReplyText(text) { /* This way, we are sure that browsers which do not support JS * won't display this link */ -function addCollapseLink(count, title) { +function addCollapseLink(count, collapsed, title) { document.write(' [-]<\/a> '); + '); return false;" title="' + title + '">[' + + (collapsed ? '+' : '−') + ']<\/a> '); } function goto_add_comments( anchor ){ diff --git a/js/util.js b/js/util.js index e0e87259f..cc4a87081 100644 --- a/js/util.js +++ b/js/util.js @@ -143,10 +143,7 @@ function bz_overlayBelow(item, parent) { */ function bz_isValueInArray(aArray, aValue) { - var run = 0; - var len = aArray.length; - - for ( ; run < len; run++) { + for (var run = 0, len = aArray.length ; run < len; run++) { if (aArray[run] == aValue) { return true; } @@ -155,6 +152,26 @@ function bz_isValueInArray(aArray, aValue) return false; } +/** + * Checks if a specified value is in the specified array by performing a + * case-insensitive comparison. + * + * @param aArray Array to search for the value. + * @param aValue Value to search from the array. + * @return Boolean; true if value is found in the array and false if not. + */ +function bz_isValueInArrayIgnoreCase(aArray, aValue) +{ + var re = new RegExp(aValue.replace(/([^A-Za-z0-9])/g, "\\$1"), 'i'); + for (var run = 0, len = aArray.length ; run < len; run++) { + if (aArray[run].match(re)) { + return true; + } + } + + return false; +} + /** * Create wanted options in a select form control. * diff --git a/show_activity.cgi b/show_activity.cgi index 27096018f..b05c1c95a 100755 --- a/show_activity.cgi +++ b/show_activity.cgi @@ -54,8 +54,8 @@ my $bug = Bugzilla::Bug->check($id); # visible immediately due to replication lag. Bugzilla->switch_to_shadow_db; -($vars->{'operations'}, $vars->{'incomplete_data'}) = - Bugzilla::Bug::GetBugActivity($bug->id); +($vars->{'operations'}, $vars->{'incomplete_data'}) = + Bugzilla::Bug::GetBugActivity($bug->id, undef, undef, 1); $vars->{'bug'} = $bug; diff --git a/skins/contrib/Mozilla/global.css b/skins/contrib/Mozilla/global.css index b0ce99cf8..4d6fd7113 100644 --- a/skins/contrib/Mozilla/global.css +++ b/skins/contrib/Mozilla/global.css @@ -559,6 +559,35 @@ table.edit_form hr { padding: 5px !important; } +.bz_comment_tags { + background: #eee; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); + border-top: 1px solid rgba(0, 0, 0, 0.1); + padding: 5px; +} + +.bz_comment_tag { + background: #fff; + color: #444; + border: none; + padding: 2px 6px; +} + +.bz_comment_tag a { + color: #0095DD; +} + +#bz_ctag_error { + border: none; + background-color: #faa; + color: #444; + padding: 2px 6px; +} + +#bz_ctag_error a { + color: #0095DD; +} + .ih_inlinehistory { background: #eee; border-top: 1px solid rgba(0, 0, 0, 0.1); diff --git a/skins/standard/show_bug.css b/skins/standard/show_bug.css index 8214ce5f4..656e130a0 100644 --- a/skins/standard/show_bug.css +++ b/skins/standard/show_bug.css @@ -116,3 +116,42 @@ table#flags { .bz_bug .bz_alias_short_desc_container { width: inherit; } + +.bz_comment_tags { + margin-top: 3px; +} + +.bz_comment_tag { + border: 1px solid #c8c8ba; + padding: 1px 3px; + margin-right: 2px; + border-radius: 0.5em; + background-color: #eee; + color: #000; +} + +#bz_ctag_div { + display: inline-block; +} + +#bz_ctag_error { + border: 1px solid #ff6666; + padding: 0px 2px; + border-radius: 0.5em; + margin: 2px; + display: inline-block; +} + +#comment_tags_collapse_expand_container { + padding-top: 1em; +} + +#comment_tags_collapse_expand { + list-style-type: none; + padding-left: 1em; +} + +#comment_tags_collapse_expand li { + margin-bottom: 0px; +} + diff --git a/template/en/default/admin/params/bugfields.html.tmpl b/template/en/default/admin/params/bugfields.html.tmpl index 58b08f615..a0d9664ad 100644 --- a/template/en/default/admin/params/bugfields.html.tmpl +++ b/template/en/default/admin/params/bugfields.html.tmpl @@ -57,5 +57,9 @@ "entry form.
" _ "You can leave this empty: " _ "$terms.Bugzilla will then use the operating system that the browser " _ - "reports to be running on as the default." } + "reports to be running on as the default.", + + collapsed_comment_tags => "A comma separated list of tags which, when applied " _ + "to comments, will cause them to be collapsed by default", + } %] diff --git a/template/en/default/admin/params/groupsecurity.html.tmpl b/template/en/default/admin/params/groupsecurity.html.tmpl index 783099a11..041af6833 100644 --- a/template/en/default/admin/params/groupsecurity.html.tmpl +++ b/template/en/default/admin/params/groupsecurity.html.tmpl @@ -42,6 +42,9 @@ querysharegroup => "The name of the group of users who can share their " _ "saved searches with others.", + comment_taggers_group => "The name of the group of users who can tag comment." _ + " Setting this to empty disables comment tagging.", + debug_group => "The name of the group of users who can view the actual " _ "SQL query generated when viewing $terms.bug lists and reports.", diff --git a/template/en/default/bug/comments.html.tmpl b/template/en/default/bug/comments.html.tmpl index 773e6a485..e0226257e 100644 --- a/template/en/default/bug/comments.html.tmpl +++ b/template/en/default/bug/comments.html.tmpl @@ -133,10 +133,13 @@ [% IF mode == "edit" %]
[% END %] @@ -151,15 +154,15 @@ [% comment_text = comment.body_full %] [% RETURN IF comment_text == '' AND (comment.work_time - 0) != 0 AND !user.is_timetracker %] -
- [% IF count == description %] +
+ [% IF comment.count == 0 %] [% class_name = "bz_first_comment_head" %] [% comment_label = "Description" %] [% ELSE %] [% class_name = "bz_comment_head" %] - [% comment_label = "Comment " _ count %] + [% comment_label = "Comment " _ comment.count %] [% END %]
@@ -168,17 +171,21 @@ [% IF comment_text.search("(?:^>|\n>)") %] [wrap] + onclick="return toggleCommentWrap(this, [% comment.count %])">wrap] [% END %] [% IF bug.check_can_change_field('longdesc', 1, 0) %] + [% IF user.can_tag_comments %] + [tag] + [% END %] [reply] [% END %] - [% END %] @@ -190,7 +197,7 @@
@@ -198,7 +205,7 @@ + href="show_bug.cgi?id=[% bug.bug_id %]#c[% comment.count %]"> [%- comment_label FILTER html %] @@ -236,11 +243,30 @@ [% PROCESS formattimeunit time_unit=comment.work_time %] [% END %] + [% IF user.id && Param('comment_taggers_group') %] +
+ + [% IF comment.tags.size %] + + [% END %] + +
+ [% END %] + [%# Don't indent the
 block, since then the spaces are displayed in the
   # generated HTML
   #%]
-
+
   [%- comment_text FILTER quoteUrls(bug, comment) -%]
 
diff --git a/template/en/default/bug/edit.html.tmpl b/template/en/default/bug/edit.html.tmpl index 696d0739f..fb596f7a7 100644 --- a/template/en/default/bug/edit.html.tmpl +++ b/template/en/default/bug/edit.html.tmpl @@ -30,6 +30,31 @@ [% PROCESS bug/time.html.tmpl %] +[% IF Param('comment_taggers_group') %] + [% IF user.can_tag_comments %] +
+ x +
+ + +
+   +
+
+ x + +
+ [% END %] + [% IF user.id %] + + [% END %] +[% END %] +