From 535a1fd09e4b150e31165e2e79af42c2e5f2bda5 Mon Sep 17 00:00:00 2001 From: Kohei Yoshino Date: Wed, 18 Jul 2018 22:44:16 -0400 Subject: Bug 1472954 - Implement one-click component watching on bug modal and component description pages --- extensions/ComponentWatching/Extension.pm | 33 +++- extensions/ComponentWatching/lib/WebService.pm | 113 +++++++++++ .../reports/components-component_footer.html.tmpl | 10 + .../reports/components-product_header.html.tmpl | 10 + .../hook/reports/components-start.html.tmpl | 12 ++ extensions/ComponentWatching/web/js/overlay.js | 218 +++++++++++++++++++++ 6 files changed, 390 insertions(+), 6 deletions(-) create mode 100644 extensions/ComponentWatching/lib/WebService.pm create mode 100644 extensions/ComponentWatching/template/en/default/hook/reports/components-component_footer.html.tmpl create mode 100644 extensions/ComponentWatching/template/en/default/hook/reports/components-product_header.html.tmpl create mode 100644 extensions/ComponentWatching/template/en/default/hook/reports/components-start.html.tmpl create mode 100644 extensions/ComponentWatching/web/js/overlay.js (limited to 'extensions/ComponentWatching') 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. + #%] + + 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. + #%] + + 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} 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} 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} 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 }); -- cgit v1.2.3-24-g4f1b