use IO::Dir;
use List::MoreUtils qw(firstidx);
use Scalar::Util qw(blessed);
-use JSON::XS qw(encode_json);
+use Mojo::JSON qw(encode_json);
+use Encode qw(encode decode);
use parent qw(Template);
},
json_encode => sub {
- return encode_json($_[0]);
+ return decode('UTF-8', encode_json($_[0]), Encode::FB_DEFAULT);
},
# Function to create date strings
[% Hook.process('comment_action', 'bug_modal/activity_stream.html.tmpl') %]
[% IF user.id %]
[% IF user.can_tag_comments %]
- <button class="tag-btn minor" type="button"
- data-id="[% comment.id FILTER none %]"
- data-no="[% comment.count FILTER none %]"
- >Tag</button>
+ <button type="button" class="tag-btn minor iconic" title="Add a tag to this comment" aria-label="Tag"
+ data-id="[% comment.id FILTER none %]" data-no="[% comment.count FILTER none %]">
+ <span class="icon" aria-hidden="true"></span>
+ </button>
[% END %]
- <button type="button" class="reply-btn minor"
- data-reply-id="[% comment.count FILTER none %]"
- data-reply-name="[% comment.author.name || comment.author.nick FILTER html %]"
- >Reply</button>
+ <button type="button" class="reply-btn minor iconic" title="Reply to this comment" aria-label="Reply"
+ data-reply-id="[% comment.count FILTER none %]"
+ data-reply-name="[% comment.author.name || comment.author.nick FILTER html %]">
+ <span class="icon" aria-hidden="true"></span>
+ </button>
[% END %]
- <button type="button" class="change-spinner minor" id="cs-[% comment.count FILTER none %]">-</button>
+ <button type="button" class="change-spinner minor iconic" id="cs-[% comment.count FILTER none %]"
+ title="Collapse this comment" aria-label="Collapse" aria-expanded="true"
+ data-strings='{ "collapse_label": "Collapse", "collapse_tooltip": "Collapse this comment",
+ "expand_label": "Expanded", "expand_tooltip": "Expanded this comment" }'>
+ <span class="icon" aria-hidden="true"></span>
+ </button>
</td>
</tr>
<div class="change-time">
[% INCLUDE bug_modal/rel_time.html.tmpl ts=comment.creation_ts %]
</div>
+ [% Hook.process('comment_meta', 'bug_modal/activity_stream.html.tmpl') %]
</td>
</tr>
Comment hidden ([% comment.collapsed_reason FILTER html %])
</td>
<td class="comment-actions">
- <button type="button" class="change-spinner minor" id="ccs-[% comment.count FILTER none %]">
- [%~ comment.collapsed ? "+" : "-" ~%]
+ <button type="button" class="change-spinner minor iconic" id="ccs-[% comment.count FILTER none %]"
+ title="[% comment.collapsed ? 'Expand this comment' : 'Collapse this comment' %]"
+ aria-label="[% comment.collapsed ? 'Expand' : 'Collapse' %]"
+ aria-expanded="[% comment.collapsed ? 'false' : 'true' %]"
+ data-strings='{ "collapse_label": "Collapse", "collapse_tooltip": "Collapse this comment",
+ "expand_label": "Expanded", "expand_tooltip": "Expanded this comment" }'>
+ <span class="icon" aria-hidden="true"></span>
</button>
</td>
</tr>
[% Hook.process('user', 'bug/changes.html.tmpl') %]
</td>
<td class="comment-actions">
- <button type="button" class="change-spinner minor" id="as-[% id FILTER none %]">-</button>
+ <button type="button" class="change-spinner minor iconic" id="as-[% id FILTER none %]"
+ title="Collapse this change" aria-label="Collapse" aria-expanded="true"
+ data-strings='{ "collapse_label": "Collapse", "collapse_tooltip": "Collapse this change",
+ "expand_label": "Expanded", "expand_tooltip": "Expanded this change" }'>
+ <span class="icon" aria-hidden="true"></span>
+ </button>
</td>
</tr>
<tr id="ar-[% id FILTER none %]">
}
-.change-name, .change-time, .comment-private {
+.change-name, .change-time {
display: inline;
}
}
.comment-actions {
+ display: flex;
+ align-items: center;
white-space: nowrap;
vertical-align: top;
padding: 2px 2px 0 0 !important;
}
+.comment-private {
+ display: inline-block;
+ margin: 0 8px;
+}
+
+.comment-actions button {
+ outline: 0;
+ margin: 0;
+}
+
+.comment-actions button:not(:first-of-type) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.comment-actions button:not(:last-of-type) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.comment-actions button.iconic:not(:disabled) {
+ color: #555;
+}
+
+.comment-actions button.iconic .icon::before {
+ font-family: 'Material Icons';
+}
+
+.comment-actions .tag-btn .icon::before {
+ content: '\E54E';
+}
+
+.comment-actions .reply-btn .icon::before {
+ content: '\E15E';
+}
+
+.comment-actions .change-spinner[aria-expanded="true"] .icon::before {
+ content: '\E15B';
+}
+
+.comment-actions .change-spinner[aria-expanded="false"] .icon::before {
+ content: '\E145';
+}
+
.change-spinner {
width: 29px;
}
// comment collapse/expand
+ const update_spinner = (spinner, expanded) => {
+ const str = spinner.data('strings');
+
+ spinner.attr({
+ 'title': expanded ? str.collapse_tooltip : str.expand_tooltip,
+ 'aria-label': expanded ? str.collapse_label : str.expand_label,
+ 'aria-expanded': expanded,
+ });
+ };
+
function toggleChange(spinner, forced) {
var spinnerID = spinner.attr('id');
var id = spinnerID.substring(spinnerID.indexOf('-') + 1);
changeSet.find(activitySelector).hide();
changeSet.find('.gravatar').css('width', '16px').css('height', '16px');
$('#ar-' + id).hide();
- spinner.text('+');
+ update_spinner(spinner, false);
}
else if (forced == 'show' || forced == 'reset') {
changeSet.find(activitySelector).show();
changeSet.find('.gravatar').css('width', '32px').css('height', '32px');
$('#ar-' + id).show();
- spinner.text('-');
+ update_spinner(spinner, true);
}
else {
changeSet.find(activitySelector).slideToggle('fast', function() {
$('#ar-' + id).toggle();
if (changeSet.find(activitySelector + ':visible').length) {
changeSet.find('.gravatar').css('width', '32px').css('height', '32px');
- spinner.text('-');
+ update_spinner(spinner, true);
}
else {
changeSet.find('.gravatar').css('width', '16px').css('height', '16px');
- spinner.text('+');
+ update_spinner(spinner, false);
}
});
}
$('#c' + id).find('.comment-tags').hide();
$('#c' + id).find('.gravatar').css('width', '16px').css('height', '16px');
$('#cr-' + id).hide();
- realSpinner.text('+');
+ update_spinner(realSpinner, false);
}
else if (forced == 'show') {
if (defaultCollapsed) {
$('#c' + id).find('.comment-tags').show();
$('#c' + id).find('.gravatar').css('width', '32px').css('height', '32px');
$('#cr-' + id).show();
- realSpinner.text('-');
+ update_spinner(realSpinner, true);
}
else {
$('#ct-' + id).slideToggle('fast', function() {
$('#c' + id).find(activitySelector).toggle();
if ($('#ct-' + id + ':visible').length) {
$('#c' + id).find('.comment-tags').show();
- realSpinner.text('-');
+ update_spinner(realSpinner, true);
$('#cr-' + id).show();
if (BUGZILLA.user.id !== 0)
$('#ctag-' + id).show();
}
else {
$('#c' + id).find('.comment-tags').hide();
- realSpinner.text('+');
+ update_spinner(realSpinner, false);
$('#cr-' + id).hide();
if (BUGZILLA.user.id !== 0)
$('#ctag-' + id).hide();
sub page_before_template {
my ($self, $args) = @_;
- return if $args->{'page_id'} ne 'editcomments.html';
+ return if $args->{'page_id'} ne 'comment-revisions.html';
my $vars = $args->{'vars'};
my $user = Bugzilla->user;
my $params = Bugzilla->input_params;
- # validate group membership
- my $edit_comments_group = Bugzilla->params->{edit_comments_group};
- if (!$edit_comments_group || !$user->in_group($edit_comments_group)) {
- ThrowUserError('auth_failure', { group => $edit_comments_group,
- action => 'view',
- object => 'editcomments' });
- }
-
my $bug_id = $params->{bug_id};
my $bug = Bugzilla::Bug->check($bug_id);
my $dbh = Bugzilla->dbh;
my $query = 'SELECT longdescs_activity.comment_id AS id, profiles.userid, ' .
- $dbh->sql_date_format('longdescs_activity.change_when', '%Y.%m.%d %H:%i:%s') . '
- AS time, longdescs_activity.old_comment AS old
+ $dbh->sql_date_format('longdescs_activity.change_when', '%Y-%m-%d %H:%i:%s') . '
+ AS time, longdescs_activity.old_comment AS old,
+ longdescs_activity.is_hidden as is_hidden
FROM longdescs_activity
INNER JOIN profiles
ON profiles.userid = longdescs_activity.who
# body that the comment was before the edit, not the actual new version
# of the comment.
my @activity;
- my $new_comment;
- my $last_old_comment;
+ my $prev_rev;
my $count = 0;
- while (my $change_ref = $sth->fetchrow_hashref()) {
- my %change = %$change_ref;
- $change{'author'} = Bugzilla::User->new({ id => $change{'userid'}, cache => 1 });
- if ($count == 0) {
- $change{new} = $self->body;
- }
- else {
- $change{new} = $new_comment;
- }
- $new_comment = $change{old};
- $last_old_comment = $change{old};
- push (@activity, \%change);
+ while (my $revision = $sth->fetchrow_hashref()) {
+ my $current = $count == 0;
+ push (@activity, {
+ author => Bugzilla::User->new({ id => $revision->{userid}, cache => 1 }),
+ created_time => $revision->{time},
+ old => $revision->{old},
+ revised_time => $current ? undef : $prev_rev->{time},
+ new => $current ? $self->body : $prev_rev->{old},
+ is_hidden => $current ? 0 : $prev_rev->{is_hidden},
+ });
+ $prev_rev = $revision;
$count++;
}
# Store the original comment as the first or last entry
# depending on sort order
push(@activity, {
- author => $self->author,
- body => $last_old_comment,
- time => $self->creation_ts,
- original => 1
+ author => $self->author,
+ created_time => $self->creation_ts,
+ revised_time => $prev_rev->{time},
+ new => $prev_rev->{old},
+ is_hidden => $prev_rev->{is_hidden},
});
$activity_sort_order
# or if editing comments is disabled
my $user = Bugzilla->user;
my $edit_comments_group = Bugzilla->params->{edit_comments_group};
- return if (!$edit_comments_group || !$user->in_group($edit_comments_group));
+ return unless $user->is_insider || $edit_comments_group && $user->in_group($edit_comments_group);
my $bug = $args->{bug};
my $timestamp = $args->{timestamp};
my ($comment_obj) = grep($_->id == $comment_id, @{ $bug->comments});
next if (!$comment_obj || ($comment_obj->is_private && !$user->is_insider));
+ # Insiders can edit any comment while unprivileged users can only edit their own comments
+ next unless $user->is_insider || $comment_obj->author->id == $user->id;
+
my $new_comment = $comment_obj->_check_thetext($params->{$param});
my $old_comment = $comment_obj->body;
next if $old_comment eq $new_comment;
+ # Insiders can hide comment revisions where needed
+ my $is_hidden = ($user->is_insider && defined $params->{"edit_comment_checkbox_$comment_id"}
+ && $params->{"edit_comment_checkbox_$comment_id"} == 'on') ? 1 : 0;
+
trick_taint($new_comment);
$dbh->do("UPDATE longdescs SET thetext = ?, edit_count = edit_count + 1
WHERE comment_id = ?",
# Log old comment to the longdescs activity table
$timestamp ||= $dbh->selectrow_array("SELECT NOW()");
$dbh->do("INSERT INTO longdescs_activity " .
- "(comment_id, who, change_when, old_comment) " .
- "VALUES (?, ?, ?, ?)",
- undef, ($comment_id, $user->id, $timestamp, $old_comment));
+ "(comment_id, who, change_when, old_comment, is_hidden) " .
+ "VALUES (?, ?, ?, ?, ?)",
+ undef, ($comment_id, $user->id, $timestamp, $old_comment, $is_hidden));
$comment_obj->{thetext} = $new_comment;
name => 'edit_comments_group',
type => 's',
choices => \&get_all_group_names,
- default => 'admin',
+ default => 'editbugs',
checker => \&check_group
};
}
use base qw(Bugzilla::WebService);
+use Bugzilla;
+use Bugzilla::Comment;
+use Bugzilla::Constants;
use Bugzilla::Error;
-use Bugzilla::Util qw(trim);
+use Bugzilla::Template;
+use Bugzilla::Util qw(trick_taint trim);
use Bugzilla::WebService::Util qw(validate);
use constant PUBLIC_METHODS => qw(
comments
+ update_comment
+ modify_revision
);
sub comments {
return { comments => \%comments };
}
+# See Bugzilla::Extension::EditComments->bug_end_of_update for the original implementation.
+# This should be migrated to the standard API method at /rest/bug/comment/(comment_id)
+sub update_comment {
+ my ($self, $params) = @_;
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+ my $edit_comments_group = Bugzilla->params->{edit_comments_group};
+
+ # Validate group membership
+ ThrowUserError('auth_failure', { group => $edit_comments_group, action => 'view', object => 'editcomments' })
+ unless $user->is_insider || $edit_comments_group && $user->in_group($edit_comments_group);
+
+ my $comment_id = (defined $params->{comment_id} && $params->{comment_id} =~ /^(\d+)$/) ? $1 : undef;
+
+ # Validate parameters
+ ThrowCodeError('param_required', { function => 'EditComments.update_comment', param => 'comment_id' })
+ unless defined $comment_id;
+ ThrowCodeError('param_required', { function => 'EditComments.update_comment', param => 'new_comment' })
+ unless defined $params->{new_comment} && trim($params->{new_comment}) ne '';
+
+ my $comment = Bugzilla::Comment->new($comment_id);
+
+ # Validate comment visibility
+ ThrowUserError('comment_id_invalid', { id => $comment_id })
+ unless $comment;
+ ThrowUserError('comment_is_private', { id => $comment->id })
+ unless $user->is_insider || !$comment->is_private;
+
+ # Insiders can edit any comment while unprivileged users can only edit their own comments
+ ThrowUserError('auth_failure', { group => 'insidergroup', action => 'view', object => 'editcomments' })
+ unless $user->is_insider || $comment->author->id == $user->id;
+
+ my $bug = $comment->bug;
+ my $old_comment = $comment->body;
+ my $new_comment = $comment->_check_thetext($params->{new_comment});
+
+ # Validate bug visibility
+ $bug->check_is_visible();
+
+ # Make sure there is any change in the comment
+ ThrowCodeError('param_no_changes', { function => 'EditComments.update_comment', param => 'new_comment' })
+ if $old_comment eq $new_comment;
+
+ my $dbh = Bugzilla->dbh;
+ my $change_when = $dbh->selectrow_array('SELECT NOW()');
+
+ # Insiders can hide comment revisions where needed
+ my $is_hidden = ($user->is_insider && defined $params->{is_hidden} && $params->{is_hidden} == 1) ? 1 : 0;
+
+ # Update the `longdescs` (comments) table
+ trick_taint($new_comment);
+ $dbh->do('UPDATE longdescs SET thetext = ?, edit_count = edit_count + 1 WHERE comment_id = ?',
+ undef, $new_comment, $comment_id);
+ Bugzilla->memcached->clear({ table => 'longdescs', id => $comment_id });
+
+ # Log old comment to the `longdescs_activity` (comment revisions) table
+ $dbh->do('INSERT INTO longdescs_activity (comment_id, who, change_when, old_comment, is_hidden)
+ VALUES (?, ?, ?, ?, ?)', undef, ($comment_id, $user->id, $change_when, $old_comment, $is_hidden));
+
+ $comment->{thetext} = $new_comment;
+ $bug->_sync_fulltext( update_comments => 1 );
+
+ # Respond with the updated comment and number of revisions
+ return {
+ text => $self->type('string', $new_comment),
+ html => $self->type('string', Bugzilla::Template::quoteUrls($new_comment, $bug)),
+ count => $self->type('int', $dbh->selectrow_array('SELECT COUNT(*) FROM longdescs_activity
+ WHERE comment_id = ?', undef, ($comment_id))),
+ };
+}
+
+sub modify_revision {
+ my ($self, $params) = @_;
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+
+ # Only allow insiders to modify revisions
+ ThrowUserError('auth_failure', { group => 'insidergroup', action => 'view', object => 'editcomments' })
+ unless $user->is_insider;
+
+ my $comment_id = (defined $params->{comment_id}
+ && $params->{comment_id} =~ /^(\d+)$/) ? $1 : undef;
+ my $change_when = (defined $params->{change_when}
+ && $params->{change_when} =~ /^(\d{4}-\d{2}-\d{2}\ \d{2}:\d{2}:\d{2})$/) ? $1 : undef;
+ my $is_hidden = defined $params->{is_hidden} && $params->{is_hidden} == 1 ? 1 : 0;
+
+ # Validate parameters
+ ThrowCodeError('param_required', { function => 'EditComments.modify_revision', param => 'comment_id' })
+ unless defined $comment_id;
+ ThrowCodeError('param_required', { function => 'EditComments.modify_revision', param => 'change_when' })
+ unless defined $change_when;
+
+ my $dbh = Bugzilla->dbh;
+
+ # Update revision visibility
+ $dbh->do('UPDATE longdescs_activity SET is_hidden = ? WHERE comment_id = ? AND change_when = ?',
+ undef, ($is_hidden, $comment_id, $change_when));
+
+ # Respond with updated revision info
+ return {
+ change_when => $self->type('dateTime', $change_when),
+ is_hidden => $self->type('boolean', $is_hidden),
+ };
+}
+
sub rest_resources {
return [
qr{^/editcomments/comment/(\d+)$}, {
return { comment_ids => $_[0] };
},
},
+ PUT => {
+ method => 'update_comment',
+ params => sub {
+ return { comment_id => $_[0] };
+ },
+ },
},
qr{^/editcomments/comment$}, {
GET => {
method => 'comments',
},
},
+ qr{^/editcomments/revision$}, {
+ PUT => {
+ method => 'modify_revision',
+ },
+ },
];
};
[% IF panel.name == "groupsecurity" %]
[% panel.param_descs.edit_comments_group =
- 'The name of the group of users who can edit comments. Leave blank to disable comment editing.'
+ 'The name of the group of users who can edit their own comments. Leave it blank to disable comment editing. ' _
+ 'Insiders can always edit any comment and hide revisions where needed.'
%]
[% END -%]
[%
RETURN IF comment.body == '';
- RETURN UNLESS Param('edit_comments_group') && user.in_group(Param('edit_comments_group'));
+ RETURN UNLESS user.is_insider
+ || Param('edit_comments_group') && user.in_group(Param('edit_comments_group')) && comment.author.id == user.id;
RETURN UNLESS
comment.type == constants.CMT_NORMAL
|| comment.type == constants.CMT_DUPE_OF
|| comment.type == constants.CMT_ATTACHMENT_UPDATED;
%]
-[% IF comment.edit_count %]
- <a href="[% basepath FILTER none %]page.cgi?id=editcomments.html&bug_id=[% bug.id FILTER none %]&comment_id=[% comment.id FILTER none %]"
- title="[% comment.edit_count FILTER none %] edit[% "s" UNLESS comment.edit_count == 1 %]">(Edited)</a>
-[% END %]
-
-<button class="edit-comment-btn minor edit-show" type="button" style="display:none"
- data-id="[% comment.id FILTER none %]"
- data-no="[% comment.count FILTER none %]">Edit</button>
+<button type="button" class="edit-btn minor iconic" title="Edit this comment" aria-label="Edit">
+ <span class="icon" aria-hidden="true"></span>
+</button>
--- /dev/null
+[%# 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.
+ #%]
+
+[% RETURN UNLESS comment.edit_count %]
+
+•
+<div class="change-revisions">
+ <a href="[% basepath FILTER none %]page.cgi?id=comment-revisions.html&bug_id=
+ [%- bug.id FILTER none %]&comment_id=[% comment.id FILTER none %]"
+ title="[% (comment.edit_count == 1 ? "%d revision" : "%d revisions")
+ FILTER replace('%d', comment.edit_count) %]">Edited</a>
+</div>
#%]
[%
- IF Param('edit_comments_group') && user.in_group(Param('edit_comments_group'));
- style_urls.push('extensions/EditComments/web/styles/editcomments.css');
- javascript_urls.push('extensions/EditComments/web/js/editcomments.js');
- END;
+ # Always load CSS to style the "Edited" revision link
+ style_urls.push('extensions/EditComments/web/styles/inline-editor.css');
+
+ RETURN UNLESS user.is_insider
+ || Param('edit_comments_group') && user.in_group(Param('edit_comments_group'));
+
+ javascript_urls.push('extensions/EditComments/web/js/inline-editor.js');
%]
--- /dev/null
+[%# 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.
+ #%]
+
+[%
+ RETURN UNLESS template.name == 'bug/show-modal.html.tmpl';
+ RETURN UNLESS user.is_insider
+ || Param('edit_comments_group') && user.in_group(Param('edit_comments_group'));
+
+ # Expose strings used in JavaScript
+ js_BUGZILLA.string.InlineCommentEditor = {
+ cancel => 'Cancel',
+ cancel_tooltip => 'Discard the changes',
+ edited => 'Edited',
+ fetch_error => 'Raw comment could not be loaded. Please try again later.',
+ hide_revision => 'Hide This Revision',
+ loading => 'Loading…',
+ revision_count => [ '%d revision', '%d revisions' ],
+ save => 'Update Comment',
+ save_error => 'Updated comment could not be saved. Please try again later.',
+ save_tooltip => 'Save the changes',
+ saving => 'Saving…',
+ toolbar => 'Comment Editor Toolbar',
+ };
+%]
--- /dev/null
+[%# 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.
+ #%]
+
+[%
+ PROCESS global/variables.none.tmpl;
+ PROCESS global/header.html.tmpl
+ title = "$terms.Bug $bug.id Comment $comment.count Edit History"
+ style_urls = ['extensions/EditComments/web/styles/revisions.css']
+ javascript_urls = (user.is_insider ? ['extensions/EditComments/web/js/revisions.js'] : [])
+ generate_api_token = 1
+%]
+
+<h2>[% "$terms.Bug $bug.id Comment $comment.count" FILTER none %] Edit History</h2>
+
+<p>
+ <strong>Note</strong>: The actual edited comment in the [% terms.bug %] view
+ page will always show the original commentor’s name and original timestamp.
+</p>
+
+[% SET rev_count = 0 %]
+[% FOREACH a = comment.activity %]
+ <article class="revision" data-comment-id="[% comment.id FILTER html %]" data-revised-time="[% a.revised_time FILTER html %]">
+ <header>
+ <div class="metadata">
+ [% a.old ? "Revision $rev_count" : "Original comment" FILTER html %]
+ by [% INCLUDE bug_modal/user.html.tmpl u=a.author %]
+ on <time datetime="[% a.created_time FILTER html %]">[% a.created_time FILTER time %]</time>
+ </div>
+ [% IF user.is_insider && a.revised_time %]
+ <div class="actions">
+ <label><input type="checkbox" name="is_hidden" [% "checked" IF a.is_hidden %]> Hide</label>
+ </div>
+ [% END %]
+ </header>
+ <div class="body">
+ [% IF !user.is_insider && a.is_hidden %]
+ <div class="hidden-comment">(Hidden by Administrator)</div>
+ [% ELSE %]
+ <pre class="bz_comment_text">[% a.new FILTER quoteUrls(bug) %]</pre>
+ [% END %]
+ </div>
+ </article>
+ [% rev_count = rev_count + 1 %]
+[% END %]
+
+[% IF !comment.activity.size %]
+ <p><em>No changes have been made to this comment.</em></p>
+[% END %]
+
+<p>[% "Back to $terms.Bug $bug.id Comment $comment.count"
+ FILTER bug_link(bug, { comment_num => comment.count }) FILTER none %]</p>
+
+[% PROCESS global/footer.html.tmpl %]
+++ /dev/null
-[%# 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.
- #%]
-
-[%
- PROCESS global/variables.none.tmpl;
- PROCESS global/header.html.tmpl
- title = "$bug.id comment $comment.count Activity"
- style_urls = ['extensions/EditComments/web/styles/editcomments.css']
-%]
-
-<h4>
- Comment changes made to
- [%= "$terms.bug $bug.id comment $comment.count"
- FILTER bug_link(bug, { comment_num => comment.count })
- FILTER none %]
-</h4>
-
-<p>
- <b>Note</b>: The actual edited comment in the [% terms.bug %] view
- page will always show the original commentor's name and original timestamp.
-</p>
-
-[% FOREACH a = comment.activity %]
- <div class="edit-head">
- <div class="edit-author-when">
- [% a.original ? "Original comment" : "Revision" %]
- by [% INCLUDE bug_modal/user.html.tmpl u=a.author %]
- on [% a.time FILTER time %]
- </div>
- </div>
- <pre class="bz_comment_text">
- [%- a.original ? a.body : a.new FILTER quoteUrls(bug) -%]
- </pre>
-[% END %]
-
-[% IF comment.activity.size %]
- <p>
- [%= "Back to $terms.bug $bug.id comment $comment.count"
- FILTER bug_link(bug, { comment_num => comment.count })
- FILTER none %]
- </p>
-[% END %]
-
-[% PROCESS global/footer.html.tmpl %]
+++ /dev/null
-/* 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() {
- $('.edit-comment-btn')
- .click(function(event) {
- event.preventDefault();
- var that = $(this);
- var id = that.data('id');
- var no = that.data('no');
-
- // cancel editing
- if (that.data('editing')) {
- that.data('editing', false).text('Edit');
- $('#edit_comment_textarea_' + id).remove();
- $('#ct-' + no).show();
- return;
- }
- that.text('Unedit');
-
- // replace comment <pre> with loading message
- $('#ct-' + no)
- .hide()
- .after(
- $('<pre/>')
- .attr('id', 'edit-comment-loading-' + id)
- .addClass('edit-comment-loading')
- .text('Loading...')
- );
-
- // load original comment text
- bugzilla_ajax(
- {
- url: `${BUGZILLA.config.basepath}rest/editcomments/comment/${id}`,
- hideError: true
- },
- function(data) {
- // create editing textarea
- $('#edit-comment-loading-' + id).remove();
- that.data('editing', true);
- $('#ct-' + no)
- .after(
- $('<textarea/>')
- .attr('name', 'edit_comment_textarea_' + id)
- .attr('id', 'edit_comment_textarea_' + id)
- .addClass('edit-comment-textarea')
- .val(data.comments[id])
- );
- },
- function(message) {
- // unedit and show message
- that.data('editing', false).text('Edit');
- $('#edit-comment-loading-' + id).remove();
- $('#ct-' + no).show();
- alert(message);
- }
- );
- });
-});
--- /dev/null
+/* 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.
+ */
+
+/**
+ * Reference or define the Bugzilla app namespace.
+ * @namespace
+ */
+var Bugzilla = Bugzilla || {}; // eslint-disable-line no-var
+
+/**
+ * Iterate all comments, and initialize the inline comment editor on each.
+ */
+Bugzilla.InlineCommentEditorInit = class InlineCommentEditorInit {
+ /**
+ * Initialize a new InlineCommentEditorInit instance.
+ */
+ constructor() {
+ document.querySelectorAll('.change-set').forEach($change_set => {
+ if ($change_set.querySelector('.edit-btn')) {
+ new Bugzilla.InlineCommentEditor($change_set);
+ }
+ });
+ }
+};
+
+/**
+ * Provide the inline comment editing functionality that allows to edit and update a comment on the bug page.
+ */
+Bugzilla.InlineCommentEditor = class InlineCommentEditor {
+ /**
+ * Initialize a new InlineCommentEditor instance.
+ * @param {HTMLElement} $change_set Comment outer.
+ */
+ constructor($change_set) {
+ this.str = BUGZILLA.string.InlineCommentEditor;
+ this.comment_id = Number($change_set.querySelector('.comment').dataset.id);
+ this.commenter_id = Number($change_set.querySelector('.email').dataset.userId);
+
+ this.$change_set = $change_set;
+ this.$edit_button = $change_set.querySelector('.edit-btn');
+ this.$revisions_link = $change_set.querySelector('.change-revisions a');
+ this.$body = $change_set.querySelector('.comment-text');
+
+ this.$edit_button.addEventListener('click', event => this.edit_button_onclick(event));
+ }
+
+ /**
+ * Check if the comment is edited.
+ * @private
+ * @readonly
+ * @type {Boolean}
+ */
+ get edited() {
+ return this.$textarea.value !== this.raw_comment;
+ }
+
+ /**
+ * Check if the user is on the macOS platform.
+ * @private
+ * @readonly
+ * @type {Boolean}
+ */
+ get on_mac() {
+ return navigator.platform === 'MacIntel';
+ }
+
+ /**
+ * Called whenever the Edit button is clicked.
+ * @param {MouseEvent} event Click event.
+ */
+ edit_button_onclick(event) {
+ event.preventDefault();
+
+ this.toggle_toolbar_buttons(true);
+ this.$body.hidden = true;
+
+ // Replace the comment body with a disabled `<textarea>` filled with the text as a placeholder while retrieving the
+ // raw comment text
+ this.$body.insertAdjacentHTML('afterend',
+ `<textarea class="comment-editor-textarea" disabled>${this.$body.textContent}</textarea>`);
+ this.$textarea = this.$body.nextElementSibling;
+ this.$textarea.style.height = `${this.$textarea.scrollHeight}px`;
+
+ // Insert a toolbar that provides the Save and Cancel buttons as well as the Hide This Revision checkbox for admin
+ this.$textarea.insertAdjacentHTML('afterend',
+ `
+ <div role="toolbar" class="comment-editor-toolbar" aria-label="${this.str.toolbar}">
+ ${BUGZILLA.user.is_insider && BUGZILLA.user.id !== this.commenter_id ? `
+ <label><input type="checkbox" value="on" checked> ${this.str.hide_revision}</label>` : ''}
+ <button type="button" class="minor" data-action="cancel" title="${this.str.cancel_tooltip} (Esc)"
+ aria-keyshortcuts="Escape">${this.str.cancel}</button>
+ <button type="button" class="major" disabled data-action="save"
+ title="${this.str.save_tooltip} (${this.on_mac ? '⌘Return' : 'Ctrl+Enter'})"
+ aria-keyshortcuts="${this.on_mac ? 'Meta+Enter' : 'Ctrl+Enter'}">${this.str.save}</button>
+ </div>
+ `
+ );
+ this.$toolbar = this.$textarea.nextElementSibling;
+
+ this.$save_button = this.$toolbar.querySelector('button[data-action="save"]');
+ this.$cancel_button = this.$toolbar.querySelector('button[data-action="cancel"]');
+ this.$is_hidden_checkbox = this.$toolbar.querySelector('input[type="checkbox"]');
+
+ this.$textarea.addEventListener('input', event => this.textarea_oninput(event));
+ this.$textarea.addEventListener('keydown', event => this.textarea_onkeydown(event));
+ this.$save_button.addEventListener('click', () => this.save());
+ this.$cancel_button.addEventListener('click', () => this.finish());
+
+ // Retrieve the raw comment text
+ bugzilla_ajax({
+ url: `${BUGZILLA.config.basepath}rest/editcomments/comment/${this.comment_id}`,
+ hideError: true,
+ }, data => {
+ this.fetch_onload(data);
+ }, message => {
+ this.fetch_onerror(message);
+ });
+ }
+
+ /**
+ * Called whenever the comment `<textarea>` is edited. Enable or disable the Save button depending on the content.
+ * @param {KeyboardEvent} event `input` event.
+ */
+ textarea_oninput(event) {
+ if (event.isComposing) {
+ return;
+ }
+
+ this.$save_button.disabled = !this.edited || !!this.$textarea.value.match(/^\s*$/);
+ }
+
+ /**
+ * Called whenever any key is pressed on the comment `<textarea>`. Handle a couple of shortcut keys.
+ * @param {KeyboardEvent} event `keydown` event.
+ */
+ textarea_onkeydown(event) {
+ if (event.isComposing) {
+ return;
+ }
+
+ const { key, altKey, ctrlKey, metaKey, shiftKey } = event;
+ const accelKey = this.on_mac ? metaKey && !ctrlKey : ctrlKey;
+
+ // Accel + Enter = Save
+ if (key === 'Enter' && accelKey && !altKey && !shiftKey) {
+ this.save();
+ }
+
+ // Escape = Cancel
+ if (key === 'Escape' && !accelKey && !altKey && !shiftKey) {
+ this.finish();
+ }
+ }
+
+ /**
+ * Called whenever the Update Comment button is clicked. Upload the changes to the server.
+ */
+ save() {
+ if (!this.edited) {
+ return;
+ }
+
+ // Disable the `<textarea>` and Save button while waiting for the response
+ this.$textarea.disabled = this.$save_button.disabled = true;
+ this.$save_button.textContent = this.str.saving;
+
+ bugzilla_ajax({
+ url: `${BUGZILLA.config.basepath}rest/editcomments/comment/${this.comment_id}`,
+ type: 'PUT',
+ hideError: true,
+ data: {
+ new_comment: this.$textarea.value,
+ is_hidden: this.$is_hidden_checkbox && this.$is_hidden_checkbox.checked ? 1 : 0,
+ },
+ }, data => {
+ this.save_onsuccess(data);
+ }, message => {
+ this.save_onerror(message);
+ });
+ }
+
+ /**
+ * Finish editing by restoring the UI, once editing is complete or cancelled. Any unsaved comment will be discarded.
+ */
+ finish() {
+ this.toggle_toolbar_buttons(false);
+ this.$edit_button.focus();
+ this.$body.hidden = false;
+ this.$textarea.remove();
+ this.$toolbar.remove();
+ }
+
+ /**
+ * Enable or disable buttons on the comment actions toolbar (not the editor's own toolbar) while editing the comment
+ * to avoid any unexpected behaviour.
+ * @param {Boolean} disabled Whether the buttons should be disabled.
+ */
+ toggle_toolbar_buttons(disabled) {
+ this.$change_set.querySelectorAll('.comment-actions button').forEach($button => $button.disabled = disabled);
+ }
+
+ /**
+ * Called whenever a raw comment text is successfully retrieved. Fill in the `<textarea>` so user can start editing.
+ * @param {Object} data Response data.
+ */
+ fetch_onload(data) {
+ this.$textarea.value = this.raw_comment = data.comments[this.comment_id];
+ this.$textarea.style.height = `${this.$textarea.scrollHeight}px`;
+ this.$textarea.disabled = false;
+ this.$textarea.focus();
+ this.$textarea.selectionStart = this.$textarea.value.length;
+
+ // Add `name` attribute to form widgets so the revision can also be submitted while saving the entire bug
+ this.$textarea.name = `edit_comment_textarea_${this.comment_id}`;
+ this.$is_hidden_checkbox ? this.$is_hidden_checkbox.name = `edit_comment_checkbox_${this.comment_id}` : '';
+ }
+
+ /**
+ * Called whenever a raw comment text could not be retrieved. Restore the UI, and display an error message.
+ * @param {String} message Error message.
+ */
+ fetch_onerror(message) {
+ this.finish();
+ window.alert(`${this.str.fetch_error}\n\n${message}`);
+ }
+
+ /**
+ * Called whenever an updated comment is successfully saved. Restore the UI, and insert/update the revision link.
+ * @param {Object} data Response data.
+ */
+ save_onsuccess(data) {
+ this.$body.innerHTML = data.html;
+ this.finish();
+
+ if (!this.$revisions_link) {
+ const $time = this.$change_set.querySelector('.change-time');
+ const params = new URLSearchParams({
+ id: 'comment-revisions.html',
+ bug_id: BUGZILLA.bug_id,
+ comment_id: this.comment_id,
+ });
+
+ $time.insertAdjacentHTML('afterend',
+ `
+ •
+ <div class="change-revisions">
+ <a href="${BUGZILLA.config.basepath}page.cgi?${params.toString().htmlEncode()}">${this.str.edited}</a>
+ </div>
+ `
+ );
+ this.$revisions_link = $time.nextElementSibling.querySelector('a');
+ }
+
+ this.$revisions_link.title = this.str.revision_count[data.count === 1 ? 0 : 1].replace('%d', data.count);
+ }
+
+ /**
+ * Called whenever an updated comment could not be saved. Re-enable the `<textarea>` and Save button, and display an
+ * error message.
+ * @param {String} message Error message.
+ */
+ save_onerror(message) {
+ this.$textarea.disabled = this.$save_button.disabled = false;
+ this.$save_button.textContent = this.str.save;
+
+ window.alert(`${this.str.save_error}\n\n${message}`);
+ }
+};
+
+window.addEventListener('DOMContentLoaded', () => new Bugzilla.InlineCommentEditorInit());
--- /dev/null
+/* 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.
+ */
+
+/**
+ * Reference or define the Bugzilla app namespace.
+ * @namespace
+ */
+var Bugzilla = Bugzilla || {}; // eslint-disable-line no-var
+
+/**
+ * Allow admin to hide specific comment revisions from public, in case any of these contains sensitive info.
+ */
+Bugzilla.CommentRevisionsManager = class CommentRevisionsManager {
+ /**
+ * Initialize a new CommentRevisionsManager instance.
+ */
+ constructor() {
+ document.querySelectorAll('.revision').forEach($revision => this.activate($revision));
+ }
+
+ /**
+ * Activate the "Hide" checkbox on each revision so the change triggers an immediate update to the database.
+ * @param {HTMLElement} $revision Revision container node.
+ */
+ activate($revision) {
+ const $checkbox = $revision.querySelector('input[name="is_hidden"]');
+
+ // The current revision cannot be hidden so there is no checkbox on it
+ if (!$checkbox) {
+ return;
+ }
+
+ const comment_id = Number($revision.dataset.commentId);
+ const change_when = $revision.dataset.revisedTime;
+
+ $checkbox.addEventListener('change', () => {
+ bugzilla_ajax({
+ url: `${BUGZILLA.config.basepath}rest/editcomments/revision`,
+ type: 'PUT',
+ data: {
+ comment_id,
+ change_when,
+ is_hidden: $checkbox.checked ? 1 : 0,
+ },
+ });
+ });
+ }
+};
+
+window.addEventListener('DOMContentLoaded', () => new Bugzilla.CommentRevisionsManager());
+++ /dev/null
-/* 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. */
-
-.edit-comment-textarea {
- width: 100%;
- height: 15em;
-}
-
-.edit-comment-loading {
- background: #fff;
- padding: 8px;
- border-top: 1px solid #ddd;
- margin: 1px 0 0;
- font-style: italic;
-}
-
-.edit-head {
- width: 50em;
- padding: 10px;
-}
-
-.edit-head .vcard {
- display: inline;
-}
--- /dev/null
+/* 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. */
+
+.change-revisions {
+ display: inline;
+ font-size: small;
+}
+
+.comment-actions .edit-btn .icon::before {
+ content: '\E254';
+}
+
+.comment-editor-toolbar {
+ padding: 8px;
+ background-color: #EEE;
+ text-align: right;
+}
+
+.comment-editor-toolbar label {
+ margin: 0 8px;
+}
+
+.comment-editor-textarea {
+ border: 0;
+ border-radius: 0;
+ padding: 8px;
+ width: 100%;
+ min-height: 5em;
+ font: 13px/1.2 "Droid Sans Mono", Menlo, Monaco, "Courier New", Courier, monospace;
+ resize: vertical;
+}
+
+.comment-editor-textarea:disabled {
+ background-color: #F3F3F3;
+}
--- /dev/null
+/* 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. */
+
+.revision header {
+ display: flex;
+ padding: 10px;
+ width: 50em;
+}
+
+.revision .metadata {
+ flex: auto;
+}
+
+.revision .metadata .vcard {
+ display: inline;
+}
+
+.revision .actions {
+ flex: none;
+}
+
+.revision .hidden-comment {
+ padding: 10px;
+ font-style: italic;
+}
+
+.revision .bz_comment_text {
+ font: 13px/1.2 "Droid Sans Mono", Menlo, Monaco, "Courier New", Courier, monospace;
+}
check_page_load($sel, WAIT_TIME, q{http://HOSTNAME:8000/bmo/editgroups.cgi});
$sel->title_is("Edit Groups");
$sel->click_ok("link=Master");
-check_page_load($sel, WAIT_TIME, q{http://HOSTNAME:8000/bmo/editgroups.cgi?action=changeform&group=26});
+check_page_load($sel, WAIT_TIME, q{http://HOSTNAME:8000/bmo/editgroups.cgi?action=changeform&group=25});
$sel->title_is("Change Group: Master");
my $group_url = $sel->get_location();
$group_url =~ /group=(\d+)$/;
bug_group => 1,
all_products => 1,
},
- {
- name => 'can_edit_comments',
- description => 'Members of this group will be able to edit comments',
- no_admin => 0,
- bug_group => 0,
- all_products => 0,
- },
{
name => 'can_restrict_comments',
description => 'Members of this group will be able to restrict comments on bugs',
'&emailtype2=exact&order=Importance&keywords_type=allwords' .
'&long_desc_type=substring',
defaultseverity => 'normal',
- edit_comments_group => 'can_edit_comments',
+ edit_comments_group => 'editbugs',
insidergroup => 'core-security-release',
last_visit_keep_days => '28',
lxr_url => 'http://mxr.mozilla.org/mozilla',
box-shadow: 0 1px 0 0 rgba(0,0,0,0.1), inset 0 -1px 0 0 rgba(0,0,0,0.1), inset 0 0 1px 0 rgba(0,0,0,0.1);
}
-button.minor:hover {
+button.minor:not(:disabled):hover,
+button.minor:not(:disabled):focus,
+button.minor:not(:disabled):active {
-webkit-box-shadow: 0 1px 0 0 rgba(0,0,0,0.2), inset 0 -1px 0 0 rgba(0,0,0,0.3), inset 0 12px 24px 2px #ddd;
-moz-box-shadow: 0 1px 0 0 rgba(0,0,0,0.2), inset 0 -1px 0 0 rgba(0,0,0,0.3), inset 0 12px 24px 2px #ddd;
box-shadow: 0 1px 0 0 rgba(0,0,0,0.1), inset 0 -1px 0 0 rgba(0,0,0,0.1), inset 0 12px 24px 2px #ddd;
color: #999;
}
+button::-moz-focus-inner {
+ border: 0;
+}
+
.notransition {
-webkit-transition: none !important;
-moz-transition: none !important;
Invalid parameter <code>[% param FILTER html %]</code> passed to
<code>[% function FILTER html %]</code>: It must be numeric.
+ [% ELSIF error == "param_no_changes" %]
+ [% title = "Invalid Parameter" %]
+ Invalid parameter <code>[% param FILTER html %]</code> passed to
+ <code>[% function FILTER html %]</code>: It must be different from the current value.
+
[% ELSIF error == "param_required" %]
[% title = "Missing Parameter" %]
The function <code>[% function FILTER html %]</code> requires