summaryrefslogtreecommitdiffstats
path: root/extensions/ComponentWatching
diff options
context:
space:
mode:
Diffstat (limited to 'extensions/ComponentWatching')
-rw-r--r--extensions/ComponentWatching/Config.pm12
-rw-r--r--extensions/ComponentWatching/Extension.pm498
-rw-r--r--extensions/ComponentWatching/template/en/default/account/prefs/component_watch.html.tmpl232
-rw-r--r--extensions/ComponentWatching/template/en/default/hook/account/prefs/email-relationships.html.tmpl10
-rw-r--r--extensions/ComponentWatching/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl14
-rw-r--r--extensions/ComponentWatching/template/en/default/hook/admin/components/edit-common-rows.html.tmpl20
-rw-r--r--extensions/ComponentWatching/template/en/default/hook/admin/components/list-before_table.html.tmpl17
-rw-r--r--extensions/ComponentWatching/template/en/default/hook/global/messages-component_updated_fields.html.tmpl15
-rw-r--r--extensions/ComponentWatching/template/en/default/hook/global/reason-descs-end.none.tmpl10
-rw-r--r--extensions/ComponentWatching/template/en/default/hook/global/user-error-errors.html.tmpl17
10 files changed, 845 insertions, 0 deletions
diff --git a/extensions/ComponentWatching/Config.pm b/extensions/ComponentWatching/Config.pm
new file mode 100644
index 000000000..560b5c3c5
--- /dev/null
+++ b/extensions/ComponentWatching/Config.pm
@@ -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.
+
+package Bugzilla::Extension::ComponentWatching;
+use strict;
+use constant NAME => 'ComponentWatching';
+
+__PACKAGE__->NAME;
diff --git a/extensions/ComponentWatching/Extension.pm b/extensions/ComponentWatching/Extension.pm
new file mode 100644
index 000000000..45d6127bf
--- /dev/null
+++ b/extensions/ComponentWatching/Extension.pm
@@ -0,0 +1,498 @@
+# 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;
+use strict;
+use base qw(Bugzilla::Extension);
+
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Group;
+use Bugzilla::User;
+use Bugzilla::User::Setting;
+use Bugzilla::Util qw(trim);
+
+our $VERSION = '2';
+
+use constant REL_COMPONENT_WATCHER => 15;
+
+#
+# installation
+#
+
+sub db_schema_abstract_schema {
+ my ($self, $args) = @_;
+ $args->{'schema'}->{'component_watch'} = {
+ FIELDS => [
+ user_id => {
+ TYPE => 'INT3',
+ NOTNULL => 1,
+ REFERENCES => {
+ TABLE => 'profiles',
+ COLUMN => 'userid',
+ DELETE => 'CASCADE',
+ }
+ },
+ component_id => {
+ TYPE => 'INT2',
+ NOTNULL => 0,
+ REFERENCES => {
+ TABLE => 'components',
+ COLUMN => 'id',
+ DELETE => 'CASCADE',
+ }
+ },
+ product_id => {
+ TYPE => 'INT2',
+ NOTNULL => 0,
+ REFERENCES => {
+ TABLE => 'products',
+ COLUMN => 'id',
+ DELETE => 'CASCADE',
+ }
+ },
+ ],
+ };
+}
+
+sub install_update_db {
+ my $dbh = Bugzilla->dbh;
+ $dbh->bz_add_column(
+ 'components',
+ 'watch_user',
+ {
+ TYPE => 'INT3',
+ REFERENCES => {
+ TABLE => 'profiles',
+ COLUMN => 'userid',
+ DELETE => 'SET NULL',
+ }
+ }
+ );
+}
+
+#
+# templates
+#
+
+sub template_before_create {
+ my ($self, $args) = @_;
+ my $config = $args->{config};
+ my $constants = $config->{CONSTANTS};
+ $constants->{REL_COMPONENT_WATCHER} = REL_COMPONENT_WATCHER;
+}
+
+#
+# user-watch
+#
+
+BEGIN {
+ *Bugzilla::Component::watch_user = \&_component_watch_user;
+}
+
+sub _component_watch_user {
+ my ($self) = @_;
+ return unless $self->{watch_user};
+ $self->{watch_user_object} ||= Bugzilla::User->new($self->{watch_user});
+ return $self->{watch_user_object};
+}
+
+sub object_columns {
+ my ($self, $args) = @_;
+ my $class = $args->{class};
+ my $columns = $args->{columns};
+ return unless $class->isa('Bugzilla::Component');
+
+ push(@$columns, 'watch_user');
+}
+
+sub object_update_columns {
+ my ($self, $args) = @_;
+ my $object = $args->{object};
+ my $columns = $args->{columns};
+ return unless $object->isa('Bugzilla::Component');
+
+ push(@$columns, 'watch_user');
+
+ # editcomponents.cgi doesn't call set_all, so we have to do this here
+ my $input = Bugzilla->input_params;
+ $object->set('watch_user', $input->{watch_user});
+}
+
+sub object_validators {
+ my ($self, $args) = @_;
+ my $class = $args->{class};
+ my $validators = $args->{validators};
+ return unless $class->isa('Bugzilla::Component');
+
+ $validators->{watch_user} = \&_check_watch_user;
+}
+
+sub object_before_create {
+ my ($self, $args) = @_;
+ my $class = $args->{class};
+ my $params = $args->{params};
+ return unless $class->isa('Bugzilla::Component');
+
+ my $input = Bugzilla->input_params;
+ $params->{watch_user} = $input->{watch_user};
+}
+
+sub object_end_of_update {
+ my ($self, $args) = @_;
+ my $object = $args->{object};
+ my $old_object = $args->{old_object};
+ my $changes = $args->{changes};
+ return unless $object->isa('Bugzilla::Component');
+
+ my $old_id = $old_object->watch_user ? $old_object->watch_user->id : 0;
+ my $new_id = $object->watch_user ? $object->watch_user->id : 0;
+ return if $old_id == $new_id;
+
+ $changes->{watch_user} = [ $old_id ? $old_id : undef, $new_id ? $new_id : undef ];
+}
+
+sub _check_watch_user {
+ my ($self, $value, $field) = @_;
+ $value = trim($value || '');
+ if ($value eq '') {
+ ThrowUserError('component_watch_missing_watch_user');
+ }
+ if ($value !~ /\.bugs$/i) {
+ ThrowUserError('component_watch_invalid_watch_user');
+ }
+ return Bugzilla::User->check($value)->id;
+}
+
+#
+# preferences
+#
+
+sub user_preferences {
+ my ($self, $args) = @_;
+ my $tab = $args->{'current_tab'};
+ return unless $tab eq 'component_watch';
+
+ my $save = $args->{'save_changes'};
+ my $handled = $args->{'handled'};
+ my $vars = $args->{'vars'};
+ my $user = Bugzilla->user;
+ my $input = Bugzilla->input_params;
+
+ if ($save) {
+ my ($sth, $sthAdd, $sthDel);
+
+ if ($input->{'add'} && $input->{'add_product'}) {
+ # add watch
+
+ my $productName = $input->{'add_product'};
+ my $ra_componentNames = $input->{'add_component'};
+ $ra_componentNames = [$ra_componentNames || ''] unless ref($ra_componentNames);
+
+ # load product and verify access
+ my $product = Bugzilla::Product->new({ name => $productName });
+ unless ($product && $user->can_access_product($product)) {
+ ThrowUserError('product_access_denied', { product => $productName });
+ }
+
+ if (grep { $_ eq '' } @$ra_componentNames) {
+ # watching a product
+ _addProductWatch($user, $product);
+
+ } else {
+ # watching specific components
+ foreach my $componentName (@$ra_componentNames) {
+ my $component = Bugzilla::Component->new({ name => $componentName, product => $product });
+ unless ($component) {
+ ThrowUserError('product_access_denied', { product => $productName });
+ }
+ _addComponentWatch($user, $component);
+ }
+ }
+
+ _addDefaultSettings($user);
+
+ } else {
+ # remove watch(s)
+
+ foreach my $name (keys %$input) {
+ if ($name =~ /^del_(\d+)$/) {
+ _deleteProductWatch($user, $1);
+ } elsif ($name =~ /^del_(\d+)_(\d+)$/) {
+ _deleteComponentWatch($user, $1, $2);
+ }
+ }
+ }
+ }
+
+ $vars->{'add_product'} = $input->{'product'};
+ $vars->{'add_component'} = $input->{'component'};
+ $vars->{'watches'} = _getWatches($user);
+ $vars->{'user_watches'} = _getUserWatches($user);
+
+ $$handled = 1;
+}
+
+#
+# bugmail
+#
+
+sub bugmail_recipients {
+ my ($self, $args) = @_;
+ my $bug = $args->{'bug'};
+ my $recipients = $args->{'recipients'};
+ my $diffs = $args->{'diffs'};
+
+ my ($oldProductId, $newProductId) = ($bug->product_id, $bug->product_id);
+ my ($oldComponentId, $newComponentId) = ($bug->component_id, $bug->component_id);
+
+ # notify when the product/component is switch from one being watched
+ if (@$diffs) {
+ # we need the product to process the component, so scan for that first
+ my $product;
+ foreach my $ra (@$diffs) {
+ next if !(exists $ra->{'old'}
+ && exists $ra->{'field_name'});
+ if ($ra->{'field_name'} eq 'product') {
+ $product = Bugzilla::Product->new({ name => $ra->{'old'} });
+ $oldProductId = $product->id;
+ }
+ }
+ if (!$product) {
+ $product = Bugzilla::Product->new($oldProductId);
+ }
+ foreach my $ra (@$diffs) {
+ next if !(exists $ra->{'old'}
+ && exists $ra->{'field_name'});
+ if ($ra->{'field_name'} eq 'component') {
+ my $component = Bugzilla::Component->new({ name => $ra->{'old'}, product => $product });
+ $oldComponentId = $component->id;
+ }
+ }
+ }
+
+ # add component watchers
+ my $dbh = Bugzilla->dbh;
+ my $sth = $dbh->prepare("
+ SELECT user_id
+ FROM component_watch
+ WHERE ((product_id = ? OR product_id = ?) AND component_id IS NULL)
+ OR (component_id = ? OR component_id = ?)
+ ");
+ $sth->execute($oldProductId, $newProductId, $oldComponentId, $newComponentId);
+ while (my ($uid) = $sth->fetchrow_array) {
+ if (!exists $recipients->{$uid}) {
+ $recipients->{$uid}->{+REL_COMPONENT_WATCHER} = Bugzilla::BugMail::BIT_WATCHING();
+ }
+ }
+
+ # add component watchers from watch-users
+ my $uidList = join(',', keys %$recipients);
+ $sth = $dbh->prepare("
+ SELECT component_watch.user_id
+ FROM components
+ INNER JOIN component_watch ON component_watch.component_id = components.id
+ WHERE components.watch_user in ($uidList)
+ ");
+ $sth->execute();
+ while (my ($uid) = $sth->fetchrow_array) {
+ if (!exists $recipients->{$uid}) {
+ $recipients->{$uid}->{+REL_COMPONENT_WATCHER} = Bugzilla::BugMail::BIT_WATCHING();
+ }
+ }
+
+ # add watch-users from component watchers
+ $sth = $dbh->prepare("
+ SELECT watch_user
+ FROM components
+ WHERE (id = ? OR id = ?)
+ AND (watch_user IS NOT NULL)
+ ");
+ $sth->execute($oldComponentId, $newComponentId);
+ while (my ($uid) = $sth->fetchrow_array) {
+ if (!exists $recipients->{$uid}) {
+ $recipients->{$uid}->{+REL_COMPONENT_WATCHER} = Bugzilla::BugMail::BIT_DIRECT();
+ }
+ }
+}
+
+sub bugmail_relationships {
+ my ($self, $args) = @_;
+ my $relationships = $args->{relationships};
+ $relationships->{+REL_COMPONENT_WATCHER} = 'Component-Watcher';
+}
+
+#
+# db
+#
+
+sub _getWatches {
+ my ($user) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $sth = $dbh->prepare("
+ SELECT product_id, component_id
+ FROM component_watch
+ WHERE user_id = ?
+ ");
+ $sth->execute($user->id);
+ my @watches;
+ while (my ($productId, $componentId) = $sth->fetchrow_array) {
+ my $product = Bugzilla::Product->new($productId);
+ next unless $product && $user->can_access_product($product);
+
+ my %watch = ( product => $product );
+ if ($componentId) {
+ my $component = Bugzilla::Component->new($componentId);
+ next unless $component;
+ $watch{'component'} = $component;
+ }
+
+ push @watches, \%watch;
+ }
+
+ @watches = sort {
+ $a->{'product'}->name cmp $b->{'product'}->name
+ || $a->{'component'}->name cmp $b->{'component'}->name
+ } @watches;
+
+ return \@watches;
+}
+
+sub _getUserWatches {
+ my ($user) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $sth = $dbh->prepare("
+ SELECT components.product_id, components.id as component, profiles.login_name
+ FROM watch
+ INNER JOIN components ON components.watch_user = watched
+ INNER JOIN profiles ON profiles.userid = watched
+ WHERE watcher = ?
+ ");
+ $sth->execute($user->id);
+ my @watches;
+ while (my ($productId, $componentId, $login) = $sth->fetchrow_array) {
+ my $product = Bugzilla::Product->new($productId);
+ next unless $product && $user->can_access_product($product);
+
+ my %watch = (
+ product => $product,
+ component => Bugzilla::Component->new($componentId),
+ user => Bugzilla::User->check($login),
+ );
+ push @watches, \%watch;
+ }
+
+ @watches = sort {
+ $a->{'product'}->name cmp $b->{'product'}->name
+ || $a->{'component'}->name cmp $b->{'component'}->name
+ } @watches;
+
+ return \@watches;
+}
+
+sub _addProductWatch {
+ my ($user, $product) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $sth = $dbh->prepare("
+ SELECT 1
+ FROM component_watch
+ WHERE user_id = ? AND product_id = ? AND component_id IS NULL
+ ");
+ $sth->execute($user->id, $product->id);
+ return if $sth->fetchrow_array;
+
+ $sth = $dbh->prepare("
+ DELETE FROM component_watch
+ WHERE user_id = ? AND product_id = ?
+ ");
+ $sth->execute($user->id, $product->id);
+
+ $sth = $dbh->prepare("
+ INSERT INTO component_watch(user_id, product_id)
+ VALUES (?, ?)
+ ");
+ $sth->execute($user->id, $product->id);
+}
+
+sub _addComponentWatch {
+ my ($user, $component) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $sth = $dbh->prepare("
+ SELECT 1
+ FROM component_watch
+ WHERE user_id = ?
+ AND (component_id = ? OR (product_id = ? AND component_id IS NULL))
+ ");
+ $sth->execute($user->id, $component->id, $component->product_id);
+ return if $sth->fetchrow_array;
+
+ $sth = $dbh->prepare("
+ INSERT INTO component_watch(user_id, product_id, component_id)
+ VALUES (?, ?, ?)
+ ");
+ $sth->execute($user->id, $component->product_id, $component->id);
+}
+
+sub _deleteProductWatch {
+ my ($user, $productId) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $sth = $dbh->prepare("
+ DELETE FROM component_watch
+ WHERE user_id = ? AND product_id = ? AND component_id IS NULL
+ ");
+ $sth->execute($user->id, $productId);
+}
+
+sub _deleteComponentWatch {
+ my ($user, $productId, $componentId) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $sth = $dbh->prepare("
+ DELETE FROM component_watch
+ WHERE user_id = ? AND product_id = ? AND component_id = ?
+ ");
+ $sth->execute($user->id, $productId, $componentId);
+}
+
+sub _addDefaultSettings {
+ my ($user) = @_;
+ my $dbh = Bugzilla->dbh;
+
+ my $sth = $dbh->prepare("
+ SELECT 1
+ FROM email_setting
+ WHERE user_id = ? AND relationship = ?
+ ");
+ $sth->execute($user->id, REL_COMPONENT_WATCHER);
+ return if $sth->fetchrow_array;
+
+ my @defaultEvents = (
+ EVT_OTHER,
+ EVT_COMMENT,
+ EVT_ATTACHMENT,
+ EVT_ATTACHMENT_DATA,
+ EVT_PROJ_MANAGEMENT,
+ EVT_OPENED_CLOSED,
+ EVT_KEYWORD,
+ EVT_DEPEND_BLOCK,
+ EVT_BUG_CREATED,
+ );
+ foreach my $event (@defaultEvents) {
+ $dbh->do(
+ "INSERT INTO email_setting(user_id,relationship,event) VALUES (?,?,?)",
+ undef,
+ $user->id, REL_COMPONENT_WATCHER, $event
+ );
+ }
+}
+
+__PACKAGE__->NAME;
diff --git a/extensions/ComponentWatching/template/en/default/account/prefs/component_watch.html.tmpl b/extensions/ComponentWatching/template/en/default/account/prefs/component_watch.html.tmpl
new file mode 100644
index 000000000..8c193a056
--- /dev/null
+++ b/extensions/ComponentWatching/template/en/default/account/prefs/component_watch.html.tmpl
@@ -0,0 +1,232 @@
+[%# 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.
+ #%]
+
+[%# initialise product to component mapping #%]
+
+[% SET selectable_products = user.get_selectable_products %]
+[% SET dont_show_button = 1 %]
+
+<script>
+var Dom = YAHOO.util.Dom;
+var useclassification = false;
+var first_load = true;
+var last_sel = [];
+var cpts = new Array();
+var watch_users = new Array();
+[% n = 0 %]
+[% FOREACH prod = selectable_products %]
+ cpts['[% n %]'] = [
+ [%- FOREACH comp = prod.components %]'[% comp.name FILTER js %]'[% ", " UNLESS loop.last %] [%- END -%] ];
+ [% n = n + 1 %]
+ [% FOREACH comp = prod.components %]
+ [% IF comp.watch_user %]
+ if (!watch_users['[% prod.name FILTER js %]'])
+ watch_users['[% prod.name FILTER js %]'] = new Array();
+ watch_users['[% prod.name FILTER js %]']['[% comp.name FILTER js %]'] = '[% comp.watch_user.login FILTER js %]';
+ [% END %]
+ [% END %]
+[% END %]
+</script>
+<script type="text/javascript" src="[% 'js/productform.js' FILTER mtime FILTER html %]">
+</script>
+
+<script>
+function onSelectProduct() {
+ var component = Dom.get('component');
+ selectProduct(Dom.get('product'), component);
+ // selectProduct only supports __Any__ on both elements
+ // we only want it on component, so add it back in
+ try {
+ component.add(new Option('__Any__', ''), component.options[0]);
+ } catch(e) {
+ // support IE
+ component.add(new Option('__Any__', ''), 0);
+ }
+ if ('[% add_component FILTER js %]' != ''
+ && bz_valueSelected(Dom.get('product'), '[% add_product FILTER js %]')
+ ) {
+ var index = bz_optionIndex(Dom.get('component'), '[% add_component FILTER js %]');
+ if (index != -1)
+ Dom.get('component').options[index].selected = true;
+ }
+ onSelectComponent();
+}
+
+function onSelectComponent() {
+ var product_select = Dom.get('product');
+ var product = product_select.options[product_select.selectedIndex].value;
+ var component = Dom.get('component').value;
+ if (component && watch_users[product] && watch_users[product][component]) {
+ Dom.get('watch-user-email').innerHTML = watch_users[product][component];
+ Dom.get('watch-user-div').style.display = '';
+ } else {
+ Dom.get('watch-user-div').style.display = 'none';
+ }
+ Dom.get('add').disabled = Dom.get('component').selectedIndex == -1;
+}
+
+YAHOO.util.Event.onDOMReady(onSelectProduct);
+
+function onRemoveChange() {
+ var cbs = Dom.get('remove_table').getElementsByTagName('input');
+ for (var i = 0, l = cbs.length; i < l; i++) {
+ if (cbs[i].checked) {
+ Dom.get('remove').disabled = false;
+ return;
+ }
+ }
+ Dom.get('remove').disabled = true;
+}
+
+YAHOO.util.Event.onDOMReady(onRemoveChange);
+
+</script>
+
+<p>
+ Select the components you want to watch.
+ To watch all components in a product, watch "__Any__".<br>
+ Use <a href="userprefs.cgi?tab=email">Email Preferences</a> to filter which
+ notification emails you receive.
+</p>
+
+<table border="0" cellpadding="3" cellspacing="0">
+<tr>
+ <td align="right">Product:</td>
+ <td colspan="2">
+ <select name="add_product" id="product" onChange="onSelectProduct()">
+ [% FOREACH product IN selectable_products %]
+ <option [% 'selected' IF add_product == product.name %]>
+ [%~ product.name FILTER html %]</option>
+ [% END %]
+ </select>
+ </td>
+</tr>
+<tr>
+ <td align="right" valign="top">Component:</td>
+ <td>
+ <select name="add_component" id="component" multiple size="10" onChange="onSelectComponent()">
+ <option value="">__Any__</option>
+ [% FOREACH product IN selectable_products %]
+ [% FOREACH component IN product.components %]
+ <option [% 'selected' IF add_component == component.name %]>
+ [%~ component.name FILTER html %]</option>
+ [% END %]
+ [% END %]
+ </select>
+ </td>
+ <td valign="top">
+ <div id="watch-user-div"
+ title="You can also watch a component by following this user. [% ~%]
+ CC'ing this user on a [% terms.bug %] will trigger notifications to all watchers of this component."
+ style="cursor:help">
+ Watch User: <span id="watch-user-email"></span>
+ </div>
+ </td>
+</tr>
+<tr>
+ <td>&nbsp;</td>
+ <td><input type="submit" id="add" name="add" value="Add"></td>
+</tr>
+</table>
+
+<hr>
+<p>
+ You are currently watching:
+</p>
+
+[% IF watches.size %]
+
+ <table border="0" cellpadding="3" cellspacing="0" id="remove_table">
+ <tr>
+ <td>&nbsp;</td>
+ <td><b>Product</b></td>
+ <td>&nbsp;<b>Component</b></td>
+ </tr>
+ [% FOREACH watch IN watches %]
+ <tr>
+ [% IF (watch.component) %]
+ <td>
+ <input type="checkbox" onChange="onRemoveChange()" id="cwdel_[% loop.count %]" value="1"
+ name="del_[% watch.product.id FILTER html %]_[% watch.component.id FILTER html %]">
+ </td>
+ <td>
+ <label for="cwdel_[% loop.count %]">
+ [% watch.component.product.name FILTER html %]
+ </label>
+ </td>
+ <td>&nbsp;
+ <a href="buglist.cgi?product=[% watch.product.name FILTER uri ~%]
+ &component=[% watch.component.name FILTER uri %]&resolution=---">
+ [% watch.component.name FILTER html %]
+ </a>
+ </td>
+ [% ELSE %]
+ <td>
+ <input type="checkbox" onChange="onRemoveChange()" id="cwdel_[% loop.count %]" value="1"
+ name="del_[% watch.product.id FILTER html %]" value="1">
+ </td>
+ <td>
+ <label for="cwdel_[% loop.count %]">
+ [% watch.product.name FILTER html %]
+ </label>
+ </td>
+ <td>&nbsp;
+ <a href="describecomponents.cgi?product=[% watch.product.name FILTER uri %]">
+ __Any__
+ </a>
+ </td>
+ [% END %]
+ </tr>
+ [% END %]
+ </table>
+
+ <input id="remove" type="submit" value="Remove Selected">
+
+[% ELSE %]
+
+ <p>
+ <i>You are not watching any components directly.</i>
+ </p>
+
+[% END %]
+
+[% IF user_watches.size %]
+
+ <hr>
+ <p>
+ [% watches.size ? "In addition," : "However," %]
+ you are watching the following components by watching users:
+ </p>
+
+ <table border="0" cellpadding="3" cellspacing="0">
+ <tr>
+ <td><b>User</b></td>
+ <td>&nbsp;<b>Product</b></td>
+ <td>&nbsp;<b>Component</b></td>
+ </tr>
+ [% FOREACH watch IN user_watches %]
+ <tr>
+ <td>[% watch.user.login FILTER html %]</td>
+ <td>&nbsp;[% watch.component.product.name FILTER html %]</td>
+ <td>&nbsp;
+ <a href="buglist.cgi?product=[% watch.product.name FILTER uri ~%]
+ &component=[% watch.component.name FILTER uri %]&resolution=---">
+ [% watch.component.name FILTER html %]
+ </a>
+ </td>
+ </tr>
+ [% END %]
+ </table>
+
+ <p>
+ Use <a href="userprefs.cgi?tab=email#new_watched_by_you">Email Preferences</a>
+ to manage this list.
+ </p>
+
+[% END %]
+
diff --git a/extensions/ComponentWatching/template/en/default/hook/account/prefs/email-relationships.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/account/prefs/email-relationships.html.tmpl
new file mode 100644
index 000000000..69ab53751
--- /dev/null
+++ b/extensions/ComponentWatching/template/en/default/hook/account/prefs/email-relationships.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.
+ #%]
+
+[% relationships.push({ id = constants.REL_COMPONENT_WATCHER, description = "Component" }) %]
+[% no_added_removed.push(constants.REL_COMPONENT_WATCHER) %]
diff --git a/extensions/ComponentWatching/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl
new file mode 100644
index 000000000..9af22ed39
--- /dev/null
+++ b/extensions/ComponentWatching/template/en/default/hook/account/prefs/prefs-tabs.html.tmpl
@@ -0,0 +1,14 @@
+[%# 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.
+ #%]
+
+[% tabs = tabs.import([{
+ name => "component_watch",
+ label => "Component Watching",
+ link => "userprefs.cgi?tab=component_watch",
+ saveable => 1
+ }]) %]
diff --git a/extensions/ComponentWatching/template/en/default/hook/admin/components/edit-common-rows.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/admin/components/edit-common-rows.html.tmpl
new file mode 100644
index 000000000..4f92097ff
--- /dev/null
+++ b/extensions/ComponentWatching/template/en/default/hook/admin/components/edit-common-rows.html.tmpl
@@ -0,0 +1,20 @@
+[%# 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.
+ #%]
+
+<tr>
+ <th class="field_label"><label for="watch_user">Watch User:</label></th>
+ <td>
+ [% INCLUDE global/userselect.html.tmpl
+ name => "watch_user"
+ id => "watch_user"
+ value => comp.watch_user.login
+ size => 64
+ emptyok => 1
+ %]
+ </td>
+</tr>
diff --git a/extensions/ComponentWatching/template/en/default/hook/admin/components/list-before_table.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/admin/components/list-before_table.html.tmpl
new file mode 100644
index 000000000..ed8d6e350
--- /dev/null
+++ b/extensions/ComponentWatching/template/en/default/hook/admin/components/list-before_table.html.tmpl
@@ -0,0 +1,17 @@
+[%# 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.
+ #%]
+
+[% CALL columns.splice(5, 0, { name => 'watch_user', heading => 'Watch User' }) %]
+
+[% FOREACH my_component = product.components %]
+ [% overrides.watch_user.name.${my_component.name} = {
+ override_content => 1
+ content => my_component.watch_user.login
+ }
+ %]
+[% END %]
diff --git a/extensions/ComponentWatching/template/en/default/hook/global/messages-component_updated_fields.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/global/messages-component_updated_fields.html.tmpl
new file mode 100644
index 000000000..38c7e8c8a
--- /dev/null
+++ b/extensions/ComponentWatching/template/en/default/hook/global/messages-component_updated_fields.html.tmpl
@@ -0,0 +1,15 @@
+[%# 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.
+ #%]
+
+[% IF changes.watch_user.defined %]
+ [% IF comp.watch_user %]
+ <li>Watch User updated to '[% comp.watch_user.login FILTER html %]'</li>
+ [% ELSE %]
+ <li>Watch User deleted</li>
+ [% END %]
+[% END %]
diff --git a/extensions/ComponentWatching/template/en/default/hook/global/reason-descs-end.none.tmpl b/extensions/ComponentWatching/template/en/default/hook/global/reason-descs-end.none.tmpl
new file mode 100644
index 000000000..8cd67bdff
--- /dev/null
+++ b/extensions/ComponentWatching/template/en/default/hook/global/reason-descs-end.none.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.
+ #%]
+
+[% watch_reason_descs.${constants.REL_COMPONENT_WATCHER} =
+ "You are watching the component for the ${terms.bug}." %]
diff --git a/extensions/ComponentWatching/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/ComponentWatching/template/en/default/hook/global/user-error-errors.html.tmpl
new file mode 100644
index 000000000..01dbb5114
--- /dev/null
+++ b/extensions/ComponentWatching/template/en/default/hook/global/user-error-errors.html.tmpl
@@ -0,0 +1,17 @@
+[%# 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.
+ #%]
+
+[% IF error == "component_watch_invalid_watch_user" %]
+ [% title = "Invalid Watch User" %]
+ The "Watch User" must be a <b>.bugs</b> email address.<br>
+ For example: <i>accessibility-apis@core.bugs</i>
+[% ELSIF error == "component_watch_missing_watch_user" %]
+ [% title = "Missing Watch User" %]
+ You must provide a <b>.bugs</b> email address for the "Watch User".<br>
+ For example: <i>accessibility-apis@core.bugs</i>
+[% END %]