diff options
author | Kohei Yoshino <kohei.yoshino@gmail.com> | 2018-07-19 04:44:16 +0200 |
---|---|---|
committer | Dylan William Hardison <dylan@hardison.net> | 2018-07-19 04:44:16 +0200 |
commit | 535a1fd09e4b150e31165e2e79af42c2e5f2bda5 (patch) | |
tree | c2f1e9379e330a5c46611b09af7d98cb71322f1c /extensions | |
parent | 351b399991094e57e890a9291484c4ab69ca628b (diff) | |
download | bugzilla-535a1fd09e4b150e31165e2e79af42c2e5f2bda5.tar.gz bugzilla-535a1fd09e4b150e31165e2e79af42c2e5f2bda5.tar.xz |
Bug 1472954 - Implement one-click component watching on bug modal and component description pages
Diffstat (limited to 'extensions')
14 files changed, 488 insertions, 212 deletions
diff --git a/extensions/BMO/template/en/default/hook/global/header-external-links.html.tmpl b/extensions/BMO/template/en/default/hook/global/header-external-links.html.tmpl index 54a2f0e49..f79548e3d 100644 --- a/extensions/BMO/template/en/default/hook/global/header-external-links.html.tmpl +++ b/extensions/BMO/template/en/default/hook/global/header-external-links.html.tmpl @@ -15,7 +15,7 @@ <li role="presentation"> <a href="https://www.mozilla.org/" role="menuitem" tabindex="-1">Mozilla Home</a> </li> - <li role="separator" class="dropdown-separator"></li> + <li role="separator"></li> <li role="presentation"> <a href="https://www.mozilla.org/privacy/websites/" role="menuitem" tabindex="-1">Privacy</a> </li> diff --git a/extensions/BMO/template/en/default/hook/reports/components-start.html.tmpl b/extensions/BMO/template/en/default/hook/reports/components-start.html.tmpl new file mode 100644 index 000000000..a4234caa2 --- /dev/null +++ b/extensions/BMO/template/en/default/hook/reports/components-start.html.tmpl @@ -0,0 +1,10 @@ +[%# 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. + #%] + +[%# Don't show the default assignees and QA contacts %] +[% show_default_people = 0 %] diff --git a/extensions/BMO/template/en/default/reports/components.html.tmpl b/extensions/BMO/template/en/default/reports/components.html.tmpl deleted file mode 100644 index 3e23d389e..000000000 --- a/extensions/BMO/template/en/default/reports/components.html.tmpl +++ /dev/null @@ -1,99 +0,0 @@ -[%# The contents of this file are subject to the Mozilla Public - # License Version 1.1 (the "License"); you may not use this file - # except in compliance with the License. You may obtain a copy of - # the License at http://www.mozilla.org/MPL/ - # - # Software distributed under the License is distributed on an "AS - # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or - # implied. See the License for the specific language governing - # rights and limitations under the License. - # - # The Original Code is the Bugzilla Bug Tracking System. - # - # The Initial Developer of the Original Code is Netscape Communications - # Corporation. Portions created by Netscape are - # Copyright (C) 1998 Netscape Communications Corporation. All - # Rights Reserved. - # - # Contributor(s): Bradley Baetz <bbaetz@student.usyd.edu.au> - # Max Kanat-Alexander <mkanat@bugzilla.org> - #%] - -[%# INTERFACE: - # product: object. The product for which we want to display component - # descriptions. - # component: string. The name of the component to hilight in the browser - #%] - -[% title = BLOCK %] - Components for [% product.name FILTER html %] -[% END %] - -[% inline_style = BLOCK %] -.product_name { - font-size: 2em; - font-weight: normal; -} -.component_name { - font-size: 1.5em; - font-weight: normal; -} -.product_desc, .component_desc { - padding-left: 1em; - font-size: 1em; -} -.component_container { - padding-left: 1em; - margin-bottom: 1em; -} -.product_container, .instructions { - margin-bottom: 1em; -} -.component_highlight { - padding: 0 0 0 1em; -} -[% END %] - -[% PROCESS global/header.html.tmpl - style_urls = [ "skins/standard/reports.css" ] - title = title - style = inline_style -%] - -<h2>[% mark FILTER html %]</h2> - -<div class="product_container"> - <span class="product_name">[% product.name FILTER html %]</span> - <div class="product_desc"> - [% product.description FILTER html_light %] - </div> -</div> - -<div class="instructions"> - Select a component to see open [% terms.bugs %] in that component: -</div> - -[% FOREACH comp = product.components %] - [% INCLUDE describe_comp %] -[% END %] - -[% PROCESS global/footer.html.tmpl %] - -[%############################################################################%] -[%# BLOCK for components %] -[%############################################################################%] - -[% BLOCK describe_comp %] - <div class="component_container [%- IF comp.name == component_mark %] component_hilite[% END %]"> - <div class="component_name"> - <a name="[% comp.name FILTER html %]" - href="buglist.cgi?product= - [%- product.name FILTER uri %]&component= - [%- comp.name FILTER uri %]&resolution=---"> - [% comp.name FILTER html %]</a> - </div> - <div class="component_desc"> - [% comp.description FILTER html_light %] - </div> - </div> -[% END %] 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 af2077e00..36494773b 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 @@ -15,8 +15,8 @@ <div class="dropdown"> <button type="button" id="comment-tags-btn" arai-haspopup="true" aria-label="Tags Menu" aria-expanded="false" aria-controls="comment-tags-menu" class="dropdown-button minor">Tags ▾</button> - <ul id="comment-tags-menu" role="menu" tabindex="0" class="dropdown-content" style="display:none"> - <li class="dropdown-separator" role="presentation"> + <ul id="comment-tags-menu" role="menu" tabindex="0" class="dropdown-content left" style="display:none"> + <li role="presentation"> <a role="menuitem" tabindex="-1" data-comment-tag="">Reset</a> </li> </ul> @@ -24,19 +24,21 @@ <div class="dropdown"> <button type="button" id="view-menu-btn" arai-haspopup="true" aria-label="View Menu" aria-expanded="false" aria-controls="view-menu" class="dropdown-button minor">View ▾</button> - <ul id="view-menu" role="menu" tabindex="0" class="dropdown-content" style="display:none"> - <li class="dropdown-separator" role="presentation"> + <ul id="view-menu" role="menu" tabindex="0" class="dropdown-content left" style="display:none"> + <li role="presentation"> <a id="view-reset" role="menuitem" tabindex="-1">Reset</a> </li> + <li role="separator"></li> <li role="presentation"> <a id="view-collapse-all" role="menuitem" tabindex="-1">Collapse All</a> </li> <li role="presentation"> <a id="view-expand-all" role="menuitem" tabindex="-1">Expand All</a> </li> - <li class="dropdown-separator" role="presentation"> + <li role="presentation"> <a id="view-comments-only" role="menuitem" tabindex="-1">Comments Only</a> </li> + <li role="separator"></li> <li role="presentation"> <a id="view-toggle-cc" role="menuitem" tabindex="-1">Show CC Changes</a> </li> diff --git a/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl index e2c8bba26..60ed6ca49 100644 --- a/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl +++ b/extensions/BugModal/template/en/default/bug_modal/edit.html.tmpl @@ -320,24 +320,26 @@ <div class="dropdown"> <button type="button" id="action-menu-btn" aria-haspopup="true" aria-label="Actions Menu" aria-expanded="false" aria-controls="action-menu" class="dropdown-button minor">▾</button> - <ul class="dropdown-content" id="action-menu" role="menu" style="display:none;"> + <ul class="dropdown-content left" id="action-menu" role="menu" style="display:none;"> <li role="presentation"> <a id="action-reset" role="menuitem" tabindex="-1">Reset Sections</a> </li> <li role="presentation"> <a id="action-expand-all" role="menuitem" tabindex="-1">Expand All Sections</a> </li> - <li class="dropdown-separator" role="presentation"> + <li role="presentation"> <a id="action-collapse-all" role="menuitem" tabindex="-1">Collapse All Sections</a> </li> + <li role="separator"></li> [% IF user.id %] <li role="presentation"> <a id="action-add-comment" role="menuitem" tabindex="-1">Add Comment</a> </li> [% END %] - <li class="dropdown-separator" role="presentation"> + <li role="presentation"> <a id="action-last-comment" role="menuitem" tabindex="-1">Last Comment</a> </li> + <li role="separator"></li> <li role="presentation"> <a id="action-history" role="menuitem" tabindex="-1">History</a> </li> @@ -374,17 +376,29 @@ hide_on_edit = can_edit_product help = "describecomponents.cgi?product=$filtered_product" %] - <span aria-owns="product-name product-latch"> - <span role="button" aria-label="show product information" aria-expanded="false" tabindex="0" - class="spin-latch" id="product-latch" data-latch="product" data-for="product">▸</span> - <div title="show product information" tabindex="0" class="spin-toggle" - id="product-name" data-latch="product" data-for="product"> + <div class="name-info-outer dropdown"> + <span id="product-name" class="dropdown-button" tabindex="0" role="button" + aria-haspopup="menu" aria-controls="product-info"> [% bug.product FILTER html %] - </div> - <div id="product-info" style="display:none"> - [% bug.product_obj.description FILTER html_light %] - </div> - </span> + <span class="icon" aria-hidden="true">▾</span> + </span> + <aside id="product-info" class="name-info-popup dropdown-content right hover-display" hidden role="menu" + aria-label="Product description and actions"> + <header> + <div class="title">[%~ bug.product FILTER html ~%]</div> + <div class="description">[% bug.product_obj.description FILTER html_light %]</div> + </header> + <li role="separator"></li> + <div class="actions"> + <div><a href="buglist.cgi?product=[% bug.product FILTER uri %]&bug_status=__open__" + target="_blank" role="menuitem" tabindex="-1">See Other [% terms.Bugs %]</a></div> + <div><button disabled type="button" class="minor component-watching" role="menuitem" tabindex="-1" + data-product="[% bug.product FILTER html %]" + data-label-watch="Watch This Product" data-label-unwatch="Unwatch This Product" + data-source="BugModal">Watch This Product</button></div> + </div> + </aside> + </div> [% END %] [% WRAPPER bug_modal/field.html.tmpl field = bug_fields.product @@ -417,20 +431,30 @@ help = "describecomponents.cgi?product=$filtered_product&component=$filtered_component#$filtered_component" %] - <span aria-owns="component-name component-latch"> - <span role="button" aria-label="show component description" aria-expanded="false" tabindex="0" - class="spin-latch" id="component-latch" data-latch="component" data-for="component">▸</span> - <div title="show component information" tabindex="0" class="spin-toggle" id="component-name" - data-latch="#component-latch" data-for="component"> - [% bug.component FILTER html %] - </div> - <div id="component-info" style="display:none"> - <div>[% bug.component_obj.description FILTER html_light %]</div> - <a href="buglist.cgi?component=[% bug.component FILTER uri %]& - [%~ %]product=[% bug.product FILTER uri %]& - [%~ %]bug_status=__open__" target="_blank">Other [% terms.Bugs %]</a> - </div> - </span> + <div class="name-info-outer dropdown"> + <span id="component-name" class="dropdown-button" tabindex="0" role="button" + aria-haspopup="menu" aria-controls="component-info"> + [% bug.component FILTER html %] + <span class="icon" aria-hidden="true">▾</span> + </span> + <aside id="component-info" class="name-info-popup dropdown-content right hover-display" hidden role="menu" + aria-label="Component description and actions"> + <header> + <div class="title">[%~ bug.product _ " :: " _ bug.component FILTER html ~%]</div> + <div class="description">[% bug.component_obj.description FILTER html_light %]</div> + </header> + <li role="separator"></li> + <div class="actions"> + <div><a href="buglist.cgi?product=[% bug.product FILTER uri %]& + [%~ %]component=[% bug.component FILTER uri %]&bug_status=__open__" + target="_blank" role="menuitem" tabindex="-1">See Other [% terms.Bugs %]</a></div> + <div><button disabled type="button" class="minor component-watching" role="menuitem" tabindex="-1" + data-product="[% bug.product FILTER html %]" data-component="[% bug.component FILTER html %]" + data-label-watch="Watch This Component" data-label-unwatch="Unwatch This Component" + data-source="BugModal">Watch This Component</button></div> + </div> + </aside> + </div> [% END %] [%# importance %] @@ -1325,7 +1349,7 @@ <div class="dropdown"> <button type="button" id="format-btn" aria-haspopup="true" aria-label="Format [% terms.Bug %] Menu" aria-expanded="false" aria-controls="format-menu" class="dropdown-button minor">Format [% terms.Bug %] ▴</button> - <ul class="dropdown-content menu-up" id="format-menu" role="menu" style="display:none;"> + <ul class="dropdown-content left menu-up" id="format-menu" role="menu" style="display:none;"> <li role="presentation"> <a href="show_bug.cgi?format=multiple&id=[% bug.id FILTER uri %]" role="menuitem" tabindex="-1">For Printing</a> </li> @@ -1346,7 +1370,7 @@ <div class="dropdown"> <button type="button" id="new-bug-btn" aria-haspopup="true" aria-label="New/Clone [% terms.Bug %] Menu" aria-expanded="false" aria-controls="new-bug-menu" class="dropdown-button minor">New/Clone [% terms.Bug %] ▴</button> - <ul class="dropdown-content menu-up" id="new-bug-menu" role="menu" style="display:none;"> + <ul class="dropdown-content left menu-up" id="new-bug-menu" role="menu" style="display:none;"> <li role="presentation"> <a href="enter_bug.cgi" role="menuitem" tabindex="-1" target="_blank"> Create a new [% terms.bug %]</a> @@ -1355,18 +1379,20 @@ <a href="enter_bug.cgi?product=[% bug.product FILTER uri %]" role="menuitem" tabindex="-1" target="_blank">… in this product</a> </li> - <li class="dropdown-separator" role="presentation"> + <li role="presentation"> <a href="enter_bug.cgi?product=[% bug.product FILTER uri %]&component=[% bug.component FILTER uri %]" role="menuitem" tabindex="-1" target="_blank">… in this component</a> </li> + <li role="separator"></li> <li role="presentation"> <a href="enter_bug.cgi?format=__default__&product=[% bug.product FILTER uri %]&blocked=[% bug.id FILTER uri %]" role="menuitem" tabindex="-1" target="_blank">… that blocks this [% terms.bug %]</a> </li> - <li class="dropdown-separator" role="presentation"> + <li role="presentation"> <a href="enter_bug.cgi?format=__default__&product=[% bug.product FILTER uri %]&dependson=[% bug.id FILTER uri %]" role="menuitem" tabindex="-1" target="_blank">… that depends on this [% terms.bug %]</a> </li> + <li role="separator"></li> <li role="presentation"> <a href="enter_bug.cgi?format=__default__&product=[% bug.product FILTER uri %]&cloned_bug_id=[% bug.id FILTER uri %]" role="menuitem" tabindex="-1" target="_blank">… as a clone of this [% terms.bug %]</a> 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 47d97fb32..20561c760 100644 --- a/extensions/BugModal/template/en/default/bug_modal/header.html.tmpl +++ b/extensions/BugModal/template/en/default/bug_modal/header.html.tmpl @@ -54,6 +54,7 @@ "extensions/ProdCompSearch/web/js/prod_comp_search.js", "extensions/BugModal/web/bug_modal.js", "extensions/BugModal/web/comments.js", + "extensions/ComponentWatching/web/js/overlay.js", "js/bugzilla-readable-status-min.js", "js/field.js", "js/comments.js", diff --git a/extensions/BugModal/web/bug_modal.css b/extensions/BugModal/web/bug_modal.css index a8c469ad6..ee50c6b77 100644 --- a/extensions/BugModal/web/bug_modal.css +++ b/extensions/BugModal/web/bug_modal.css @@ -44,26 +44,6 @@ button.major { padding: 4px 12px; } -button.minor { - background-color: #eee; - background-image: linear-gradient(#fcfcfc, #eee); - color: #000; - font-size: inherit; - font-weight: 500; - padding: 4px 8px; - margin-bottom: 1px; - text-shadow: none; - -web-kit-box-shadow: 0 1px 0 0 rgba(0,0,0,0.1), inset 0 -1px 0 0 rgba(0,0,0,0.1); - -moz-box-shadow: 0 1px 0 0 rgba(0,0,0,0.1), inset 0 -1px 0 0 rgba(0,0,0,0.1); - 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 { - -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; -} - select[multiple], .text_input, .yui-ac-input, input { font-size: 12px !important; } @@ -329,16 +309,6 @@ input[type="number"] { margin-bottom: 50px; } -#product-info, #component-info { - color: #484; - white-space: normal; -} - -#product-latch, #component-latch { - padding-right: 0; - cursor: pointer; -} - #cc-latch { color: #999; } @@ -968,6 +938,28 @@ div.ui-tooltip { right: 8px; } +/* product/component popup */ + +.name-info-popup { + width: 320px; +} + +.name-info-popup header { + margin: 8px 16px; +} + +.name-info-popup header .title { + margin: 0 0 4px; + font-size: 16px; +} + +.name-info-popup header .description { + font-size: 12px; + line-height: 150%; + white-space: normal; + color: #666; +} + /* product search */ #field-product { diff --git a/extensions/BugModal/web/bug_modal.js b/extensions/BugModal/web/bug_modal.js index c4bff9412..a4ae83d72 100644 --- a/extensions/BugModal/web/bug_modal.js +++ b/extensions/BugModal/web/bug_modal.js @@ -1434,46 +1434,6 @@ if (history && history.replaceState) { } } -// ajax wrapper, to simplify error handling and auth -function bugzilla_ajax(request, done_fn, error_fn) { - $('#xhr-error').hide(''); - $('#xhr-error').html(''); - request.url += (request.url.match('\\?') ? '&' : '?') + - 'Bugzilla_api_token=' + encodeURIComponent(BUGZILLA.api_token); - if (request.type != 'GET') { - request.contentType = 'application/json'; - request.processData = false; - if (request.data && request.data.constructor === Object) { - request.data = JSON.stringify(request.data); - } - } - return $.ajax(request) - .done(function(data) { - if (data.error) { - if (!request.hideError) { - $('#xhr-error').html(data.message); - $('#xhr-error').show('fast'); - } - if (error_fn) - error_fn(data.message); - } - else if (done_fn) { - done_fn(data); - } - }) - .fail(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); - $('#xhr-error').show('fast'); - } - if (error_fn) - error_fn(message); - }); -} - // lightbox function lb_show(el) { diff --git a/extensions/ComponentWatching/Extension.pm b/extensions/ComponentWatching/Extension.pm index 01d843c7a..674e0da7b 100644 --- a/extensions/ComponentWatching/Extension.pm +++ b/extensions/ComponentWatching/Extension.pm @@ -467,15 +467,18 @@ sub bugmail_relationships { # sub _getWatches { - my ($user) = @_; + my ($user, $watch_id) = @_; my $dbh = Bugzilla->dbh; + $watch_id = (defined $watch_id && $watch_id =~ /^(\d+)$/) ? $1 : undef; + my $sth = $dbh->prepare(" SELECT id, product_id, component_id, component_prefix FROM component_watch - WHERE user_id = ? - "); - $sth->execute($user->id); + WHERE user_id = ?" . ($watch_id ? " AND id = ?" : "") + ); + $watch_id ? $sth->execute($user->id, $watch_id) : $sth->execute($user->id); + my @watches; while (my ($id, $productId, $componentId, $prefix) = $sth->fetchrow_array) { my $product = Bugzilla::Product->new({ id => $productId, cache => 1 }); @@ -498,6 +501,10 @@ sub _getWatches { push @watches, \%watch; } + if ($watch_id) { + return $watches[0] || {}; + } + @watches = sort { $a->{'product_name'} cmp $b->{'product_name'} || $a->{'component_name'} cmp $b->{'component_name'} @@ -563,6 +570,8 @@ sub _addProductWatch { VALUES (?, ?) "); $sth->execute($user->id, $product->id); + + return _getWatches($user, $dbh->bz_last_key()); } sub _addComponentWatch { @@ -583,6 +592,8 @@ sub _addComponentWatch { VALUES (?, ?, ?) "); $sth->execute($user->id, $component->product_id, $component->id); + + return _getWatches($user, $dbh->bz_last_key()); } sub _addPrefixWatch { @@ -618,8 +629,9 @@ sub _deleteWatch { my $dbh = Bugzilla->dbh; detaint_natural($id) || ThrowCodeError("component_watch_invalid_id"); - $dbh->do("DELETE FROM component_watch WHERE id=? AND user_id=?", - undef, $id, $user->id); + + return $dbh->do("DELETE FROM component_watch WHERE id=? AND user_id=?", + undef, $id, $user->id); } sub _addDefaultSettings { @@ -715,4 +727,13 @@ sub sanitycheck_repair { } } +# +# webservice +# + +sub webservice { + my ($self, $args) = @_; + $args->{dispatch}->{ComponentWatching} = "Bugzilla::Extension::ComponentWatching::WebService"; +} + __PACKAGE__->NAME; diff --git a/extensions/ComponentWatching/lib/WebService.pm b/extensions/ComponentWatching/lib/WebService.pm new file mode 100644 index 000000000..ba4cb0225 --- /dev/null +++ b/extensions/ComponentWatching/lib/WebService.pm @@ -0,0 +1,113 @@ +# 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::Extension::ComponentWatching::WebService; + +use 5.10.1; +use strict; +use warnings; + +use base qw(Bugzilla::WebService); + +use Bugzilla; +use Bugzilla::Component; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Product; +use Bugzilla::User; + +sub rest_resources { + return [ + qr{^/component-watching$}, { + GET => { + method => 'list', + }, + POST => { + method => 'add', + }, + }, + qr{^/component-watching/(\d+)$}, { + GET => { + method => 'get', + params => sub { + return { id => $_[0] } + }, + }, + DELETE => { + method => 'remove', + params => sub { + return { id => $_[0] } + }, + }, + }, + ]; +} + +# +# API methods based on Bugzilla::Extension::ComponentWatching->user_preferences +# + +sub list { + my ($self, $params) = @_; + my $user = Bugzilla->login(LOGIN_REQUIRED); + + return Bugzilla::Extension::ComponentWatching::_getWatches($user); +} + +sub add { + my ($self, $params) = @_; + my $user = Bugzilla->login(LOGIN_REQUIRED); + my $result; + + # load product and verify access + my $productName = $params->{'product'}; + my $product = Bugzilla::Product->new({ name => $productName, cache => 1 }); + unless ($product && $user->can_access_product($product)) { + ThrowUserError('product_access_denied', { product => $productName }); + } + + my $ra_componentNames = $params->{'component'}; + $ra_componentNames = [$ra_componentNames || ''] unless ref($ra_componentNames); + + if (grep { $_ eq '' } @$ra_componentNames) { + # watching a product + $result = Bugzilla::Extension::ComponentWatching::_addProductWatch($user, $product); + + } else { + # watching specific components + foreach my $componentName (@$ra_componentNames) { + my $component = Bugzilla::Component->new({ + name => $componentName, product => $product, cache => 1 + }); + unless ($component) { + ThrowUserError('product_access_denied', { product => $productName }); + } + $result = Bugzilla::Extension::ComponentWatching::_addComponentWatch($user, $component); + } + } + + Bugzilla::Extension::ComponentWatching::_addDefaultSettings($user); + + return $result; +} + +sub get { + my ($self, $params) = @_; + my $user = Bugzilla->login(LOGIN_REQUIRED); + + return Bugzilla::Extension::ComponentWatching::_getWatches($user, $params->{'id'}); +} + +sub remove { + my ($self, $params) = @_; + my $user = Bugzilla->login(LOGIN_REQUIRED); + my %result = (status => Bugzilla::Extension::ComponentWatching::_deleteWatch($user, $params->{'id'})); + + return \%result; +} + +1; diff --git a/extensions/ComponentWatching/template/en/default/hook/reports/components-component_footer.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/reports/components-component_footer.html.tmpl new file mode 100644 index 000000000..b8921bcf0 --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/reports/components-component_footer.html.tmpl @@ -0,0 +1,10 @@ +[%# 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. + #%] + +<button disabled type="button" class="minor component-watching" data-product="[% product.name FILTER html %]" + data-component="[% comp.name FILTER html %]" data-source="Component Description">Watch</button> diff --git a/extensions/ComponentWatching/template/en/default/hook/reports/components-product_header.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/reports/components-product_header.html.tmpl new file mode 100644 index 000000000..bc7120b4e --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/reports/components-product_header.html.tmpl @@ -0,0 +1,10 @@ +[%# 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. + #%] + +<button disabled type="button" class="minor component-watching" data-product="[% product.name FILTER html %]" + data-source="Component Description">Watch</button> diff --git a/extensions/ComponentWatching/template/en/default/hook/reports/components-start.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/reports/components-start.html.tmpl new file mode 100644 index 000000000..76cf6bc08 --- /dev/null +++ b/extensions/ComponentWatching/template/en/default/hook/reports/components-start.html.tmpl @@ -0,0 +1,12 @@ +[%# 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. + #%] + +[% + javascript_urls.push('extensions/ComponentWatching/web/js/overlay.js'); + generate_api_token = 1; +%] diff --git a/extensions/ComponentWatching/web/js/overlay.js b/extensions/ComponentWatching/web/js/overlay.js new file mode 100644 index 000000000..c0c540257 --- /dev/null +++ b/extensions/ComponentWatching/web/js/overlay.js @@ -0,0 +1,218 @@ +/* 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 || {}; + +/** + * Implement the one-click Component Watching functionality that can be added to any page. + * @abstract + */ +Bugzilla.ComponentWatching = class ComponentWatching { + /** + * Initialize a new ComponentWatching instance. Since constructors can't be async, use a separate function to move on. + */ + constructor() { + this.buttons = document.querySelectorAll('button.component-watching'); + + this.init(); + } + + /** + * Send a REST API request, and return the results in a Promise. + * @param {Object} [request={}] Request data. If omitted, all the current watches will be returned. + * @param {String} [path=''] Optional path to be appended to the request URL. + * @returns {Promise<Object|String>} Response data or error message. + */ + async fetch(request = {}, path = '') { + request.url = `/rest/component-watching${path}`; + + return new Promise((resolve, reject) => bugzilla_ajax(request, data => resolve(data), error => reject(error))); + } + + /** + * Start watching the current product or component. + * @param {String} product Product name. + * @param {String} [component=''] Component name. If omitted, all the components in the product will be watched. + * @returns {Promise<Object|String>} Response data or error message. + */ + async watch(product, component = '') { + return this.fetch({ type: 'POST', data: { product, component } }); + } + + /** + * Stop watching the current product or component. + * @param {Number} id ID of the watch to be removed. + * @returns {Promise<Object|String>} Response data or error message. + */ + async unwatch(id) { + return this.fetch({ type: 'DELETE' }, `/${id}`); + } + + /** + * Log an event with Google Analytics if possible. For privacy reasons, we don't send any specific product or + * component name. + * @param {String} source Event source that will be part of the event category. + * @param {String} action `watch` or `unwatch`. + * @param {String} type `product` or `component`. + * @param {Number} code `0` for a successful change, `1` otherwise. + * @see https://developers.google.com/analytics/devguides/collection/analyticsjs/events + */ + track_event(source, action, type, code) { + if ('ga' in window) { + ga('send', 'event', `Component Watching: ${source}`, action, type, code); + } + } + + /** + * Show a short floating message if the button is on BugModal. This code is from bug_modal.js, requiring jQuery. + * @param {String} message Message text. + */ + show_message(message) { + if (!document.querySelector('#floating-message')) { + return; + } + + $('#floating-message-text').text(message); + $('#floating-message').fadeIn(250).delay(2500).fadeOut(); + } + + /** + * Get all the component watching buttons on the current page. + * @param {String} [product] Optional product name. + * @param {String} [component] Optional component name. + * @returns {HTMLButtonElement[]} List of button elements. + */ + get_buttons(product = undefined, component = undefined) { + let buttons = [...this.buttons]; + + if (product) { + buttons = buttons.filter($button => $button.dataset.product === product); + } + + if (component) { + buttons = buttons.filter($button => $button.dataset.component === component); + } + + return buttons; + } + + /** + * Update a Watch/Unwatch button for a product or component. + * @param {HTMLButtonElement} $button Button element to be updated. + * @param {Boolean} disabled Whether the button has to be disabled. + * @param {Number} [watchId] Optional watch ID if the product or component is being watched. + */ + update_button($button, disabled, watchId = undefined) { + const { product, component } = $button.dataset; + + if (watchId) { + $button.dataset.watchId = watchId; + $button.textContent = $button.getAttribute('data-label-unwatch') || 'Unwatch'; + $button.title = component ? + `Stop watching the ${component} component` : + `Stop watching all components in the ${product} product`; + } else { + delete $button.dataset.watchId; + + $button.textContent = $button.getAttribute('data-label-watch') || 'Watch'; + $button.title = component ? + `Start watching the ${component} component` : + `Start watching all components in the ${product} product`; + } + + $button.disabled = disabled; + } + + /** + * Called whenever a Watch/Unwatch button is clicked. Send a request to update the user's watch list, and update the + * relevant buttons on the page. + * @param {HTMLButtonElement} $button Clicked button element. + */ + async button_onclick($button) { + const { product, component, watchId, source } = $button.dataset; + let message = ''; + let code = 0; + + // Disable the button until the request is complete + $button.disabled = true; + + try { + if (watchId) { + await this.unwatch(watchId); + + if (component) { + message = `You are no longer watching the ${component} component`; + + this.get_buttons(product, component).forEach($button => this.update_button($button, false)); + } else { + message = `You are no longer watching all components in the ${product} product`; + + this.get_buttons(product).forEach($button => this.update_button($button, false)); + } + } else { + const watch = await this.watch(product, component); + + if (component) { + message = `You are now watching the ${component} component`; + + this.get_buttons(product, component).forEach($button => this.update_button($button, false, watch.id)); + } else { + message = `You are now watching all components in the ${product} product`; + + this.get_buttons(product).forEach($button => { + if ($button.dataset.component) { + this.update_button($button, true); + } else { + this.update_button($button, false, watch.id); + } + }); + } + } + } catch (ex) { + message = 'Your watch list could not be updated. Please try again later.'; + code = 1; + } + + this.show_message(message); + this.track_event(source, watchId ? 'unwatch' : 'watch', component ? 'component' : 'product', code); + } + + /** + * Retrieve the current watch list, and initialize all the buttons. + */ + async init() { + try { + const all_watches = await this.fetch(); + + this.get_buttons().forEach($button => { + const { product, component } = $button.dataset; + const watches = all_watches.filter(watch => watch.product_name === product); + const product_watch = watches.find(watch => !watch.component); + + if (!component) { + // This button is for product watching + this.update_button($button, false, product_watch ? product_watch.id : undefined); + } else if (product_watch) { + // Disabled the button because all the components in the product is being watched + this.update_button($button, true); + } else { + const watch = watches.find(watch => watch.component_name === component); + + this.update_button($button, false, watch ? watch.id : undefined); + } + + $button.addEventListener('click', () => this.button_onclick($button)); + }); + } catch (ex) {} + } +}; + +window.addEventListener('DOMContentLoaded', () => new Bugzilla.ComponentWatching(), { once: true }); |