diff options
Diffstat (limited to 'extensions')
-rw-r--r-- | extensions/BMO/Extension.pm | 6 | ||||
-rw-r--r-- | extensions/BMO/lib/Reports.pm | 375 | ||||
-rw-r--r-- | extensions/BMO/template/en/default/hook/global/user-error-errors.html.tmpl | 16 | ||||
-rw-r--r-- | extensions/BMO/template/en/default/hook/reports/menu-end.html.tmpl | 5 | ||||
-rw-r--r-- | extensions/BMO/template/en/default/pages/release_tracking_report.html.tmpl | 103 | ||||
-rw-r--r-- | extensions/BMO/web/js/release_tracking_report.js | 203 | ||||
-rw-r--r-- | extensions/BMO/web/styles/reports.css | 4 |
7 files changed, 684 insertions, 28 deletions
diff --git a/extensions/BMO/Extension.pm b/extensions/BMO/Extension.pm index 3cf0e8317..265d09a29 100644 --- a/extensions/BMO/Extension.pm +++ b/extensions/BMO/Extension.pm @@ -57,7 +57,8 @@ use Bugzilla::Extension::BMO::Data qw($cf_visible_in_products use Bugzilla::Extension::BMO::Reports qw(user_activity_report triage_reports group_admins - email_queue_report); + email_queue_report + release_tracking_report); our $VERSION = '0.1'; @@ -173,6 +174,9 @@ sub page_before_template { elsif ($page eq 'email_queue.html') { email_queue_report($vars); } + elsif ($page eq 'release_tracking_report.html') { + release_tracking_report($vars); + } } sub _get_field_values_sort_key { diff --git a/extensions/BMO/lib/Reports.pm b/extensions/BMO/lib/Reports.pm index e9e2670b9..cb11de182 100644 --- a/extensions/BMO/lib/Reports.pm +++ b/extensions/BMO/lib/Reports.pm @@ -20,20 +20,26 @@ package Bugzilla::Extension::BMO::Reports; use strict; -use Bugzilla::User; -use Bugzilla::Util qw(trim detaint_natural); -use Bugzilla::Error; +use Bugzilla::Extension::BMO::Data qw($cf_disabled_flags); + use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Field; +use Bugzilla::User; +use Bugzilla::Util qw(trim detaint_natural trick_taint correct_urlbase); use Date::Parse; use DateTime; +use JSON qw(-convert_blessed_universally); +use List::MoreUtils qw(uniq); use base qw(Exporter); our @EXPORT_OK = qw(user_activity_report triage_reports group_admins - email_queue_report); + email_queue_report + release_tracking_report); sub user_activity_report { my ($vars) = @_; @@ -59,20 +65,10 @@ sub user_activity_report { } Bugzilla::User::match_field({ 'who' => {'type' => 'multi'} }); - ThrowUserError('user_activity_missing_from_date') unless $from; - my $from_time = _parse_date($from) - or ThrowUserError('user_activity_invalid_date', { date => $from }); - my $from_dt = DateTime->from_epoch(epoch => $from_time) - ->set_time_zone('local') - ->truncate(to => 'day'); + my $from_dt = _string_to_datetime($from); $from = $from_dt->ymd(); - ThrowUserError('user_activity_missing_to_date') unless $to; - my $to_time = _parse_date($to) - or ThrowUserError('user_activity_invalid_date', { date => $to }); - my $to_dt = DateTime->from_epoch(epoch => $to_time) - ->set_time_zone('local') - ->truncate(to => 'day'); + my $to_dt = _string_to_datetime($to); $to = $to_dt->ymd(); # add one day to include all activity that happened on the 'to' date $to_dt->add(days => 1); @@ -298,6 +294,20 @@ sub user_activity_report { $vars->{'to'} = $to; } +sub _string_to_datetime { + my $input = shift; + my $time = _parse_date($input) + or ThrowUserError('report_invalid_date', { date => $input }); + return _time_to_datetime($time); +} + +sub _time_to_datetime { + my $time = shift; + return DateTime->from_epoch(epoch => $time) + ->set_time_zone('local') + ->truncate(to => 'day'); +} + sub _parse_date { my ($str) = @_; if ($str =~ /^(-|\+)?(\d+)([hHdDwWmMyY])$/) { @@ -525,7 +535,7 @@ sub triage_reports { } sub group_admins { - my ($vars, $filter) = @_; + my ($vars) = @_; my $dbh = Bugzilla->dbh; my $user = Bugzilla->user; @@ -587,4 +597,335 @@ sub email_queue_report { $vars->{'now'} = (time); } +sub release_tracking_report { + my ($vars) = @_; + my $dbh = Bugzilla->dbh; + my $input = Bugzilla->input_params; + my $user = Bugzilla->user; + + my @flag_names = qw( + approval-mozilla-release + approval-mozilla-beta + approval-mozilla-aurora + approval-comm-release + approval-comm-beta + approval-comm-aurora + ); + + my @flags_json; + my @fields_json; + my @products_json; + + # + # tracking flags + # + + my $all_products = $user->get_selectable_products; + my @usable_products; + + # build list of flags and their matching products + + foreach my $flag_name (@flag_names) { + # grab all matching flag_types + my @flag_types = @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })}; + + # we need a list of products, based on inclusions/exclusions + my @products; + my %flag_types; + foreach my $flag_type (@flag_types) { + $flag_types{$flag_type->name} = $flag_type->id; + my $has_all = 0; + my @exclusion_ids; + my @inclusion_ids; + foreach my $flag_type (@flag_types) { + if (scalar keys %{$flag_type->inclusions}) { + my $inclusions = $flag_type->inclusions; + foreach my $key (keys %$inclusions) { + push @inclusion_ids, ($inclusions->{$key} =~ /^(\d+)/); + } + } elsif (scalar keys %{$flag_type->exclusions}) { + my $exclusions = $flag_type->exclusions; + foreach my $key (keys %$exclusions) { + push @exclusion_ids, ($exclusions->{$key} =~ /^(\d+)/); + } + } else { + $has_all = 1; + last; + } + } + + if ($has_all) { + push @products, @$all_products; + } elsif (scalar @exclusion_ids) { + push @products, @$all_products; + foreach my $exclude_id (uniq @exclusion_ids) { + @products = grep { $_->id != $exclude_id } @products; + } + } else { + foreach my $include_id (uniq @inclusion_ids) { + push @products, grep { $_->id == $include_id } @$all_products; + } + } + } + @products = uniq @products; + push @usable_products, @products; + my @product_ids = map { $_->id } sort { lc($a->name) cmp lc($b->name) } @products; + + push @flags_json, { + name => $flag_name, + id => $flag_types{$flag_name} || 0, + products => \@product_ids, + fields => [], + }; + } + @usable_products = uniq @usable_products; + + # build a list of tracking flags for each product + # also build the list of all fields + + my @unlink_products; + foreach my $product (@usable_products) { + my @fields = + grep { _is_active_status_field($_->name) } + Bugzilla->active_custom_fields({ product => $product }); + my @field_ids = map { $_->id } @fields; + if (!scalar @fields) { + push @unlink_products, $product; + next; + } + + # product + push @products_json, { + name => $product->name, + id => $product->id, + fields => \@field_ids, + }; + + # add fields to flags + foreach my $rh (@flags_json) { + if (grep { $_ eq $product->id } @{$rh->{products}}) { + push @{$rh->{fields}}, @field_ids; + } + } + + # add fields to fields_json + foreach my $field (@fields) { + my $existing = 0; + foreach my $rh (@fields_json) { + if ($rh->{id} == $field->id) { + $existing = 1; + last; + } + } + if (!$existing) { + push @fields_json, { + name => $field->name, + id => $field->id, + }; + } + } + } + foreach my $rh (@flags_json) { + my @fields = uniq @{$rh->{fields}}; + $rh->{fields} = \@fields; + } + + # remove products which aren't linked with status fields + + foreach my $rh (@flags_json) { + my @product_ids; + foreach my $id (@{$rh->{products}}) { + unless (grep { $_->id == $id } @unlink_products) { + push @product_ids, $id; + } + $rh->{products} = \@product_ids; + } + } + + # + # rapid release dates + # + + my @ranges; + my $start_date = _string_to_datetime('2011-08-16'); + my $end_date = $start_date->clone->add(weeks => 6)->add(days => -1); + my $now_date = _time_to_datetime((time)); + + while ($start_date <= $now_date) { + unshift @ranges, { + value => sprintf("%s-%s", $start_date->ymd(''), $end_date->ymd('')), + label => sprintf("%s and %s", $start_date->ymd('-'), $end_date->ymd('-')), + }; + + $start_date = $end_date->clone;; + $start_date->add(days => 1); + $end_date->add(weeks => 6); + } + push @ranges, { + value => '*', + label => 'Anytime', + }; + + # + # run report + # + + if ($input->{q}) { + my $q = _parse_query($input->{q}); + + my @where; + my @params; + my $query = " + SELECT b.bug_id + FROM bugs b + INNER JOIN flags f ON f.bug_id = b.bug_id "; + + if ($q->{start_date}) { + $query .= "INNER JOIN bugs_activity a ON a.bug_id = b.bug_id "; + } + + $query .= "WHERE "; + + if ($q->{start_date}) { + push @where, "(a.fieldid = ?)"; + push @params, $q->{field_id}; + + push @where, "(a.bug_when >= ?)"; + push @params, $q->{start_date} . ' 00:00:00'; + push @where, "(a.bug_when < ?)"; + push @params, $q->{end_date} . ' 00:00:00'; + + push @where, "(a.added LIKE ?)"; + push @params, '%' . $q->{flag_name} . '?%'; + } + + push @where, "(f.type_id IN (SELECT id FROM flagtypes WHERE name = ?))"; + push @params, $q->{flag_name}; + + push @where, "(f.status = ?)"; + push @params, $q->{flag_status}; + + if ($q->{product_id}) { + push @where, "(b.product_id = ?)"; + push @params, $q->{product_id}; + } + + if (scalar @{$q->{fields}}) { + my @fields; + foreach my $field (@{$q->{fields}}) { + push @fields, + "(" . + ($field->{value} eq '+' ? '' : '!') . + "(b.".$field->{name}." IN ('fixed','verified'))" . + ") "; + } + my $join = uc $q->{join}; + push @where, '(' . join(" $join ", @fields) . ')'; + } + + $query .= join("\nAND ", @where); + + my $bugs = $dbh->selectcol_arrayref($query, undef, @params); + push @$bugs, 0 unless @$bugs; + + my $urlbase = correct_urlbase(); + my $cgi = Bugzilla->cgi; + print $cgi->redirect( + -url => "${urlbase}buglist.cgi?bug_id=" . join(',', @$bugs) + ); + exit; + } + + # + # set template vars + # + + my $json = JSON->new(); + if (0) { + # debugging + $json->shrink(0); + $json->canonical(1); + $vars->{flags_json} = $json->pretty->encode(\@flags_json); + $vars->{products_json} = $json->pretty->encode(\@products_json); + $vars->{fields_json} = $json->pretty->encode(\@fields_json); + } else { + $json->shrink(1); + $vars->{flags_json} = $json->encode(\@flags_json); + $vars->{products_json} = $json->encode(\@products_json); + $vars->{fields_json} = $json->encode(\@fields_json); + } + + $vars->{flag_names} = \@flag_names; + $vars->{ranges} = \@ranges; + $vars->{default_query} = $input->{q}; + foreach my $field (qw(product flags range)) { + $vars->{$field} = $input->{$field}; + } +} + +sub _parse_query { + my $q = shift; + my @query = split(/:/, $q); + my $query; + + # field_id for flag changes + $query->{field_id} = get_field_id('flagtypes.name'); + + # flag_name + my $flag_name = shift @query; + @{Bugzilla::FlagType::match({ name => $flag_name, is_active => 1 })} + or ThrowUserError('report_invalid_parameter', { name => 'flag_name' }); + trick_taint($flag_name); + $query->{flag_name} = $flag_name; + + # flag_status + my $flag_status = shift @query; + $flag_status =~ /^([\?\-\+])$/ + or ThrowUserError('report_invalid_parameter', { name => 'flag_status' }); + $query->{flag_status} = $1; + + # date_range -> from_ymd to_ymd + my $date_range = shift @query; + if ($date_range ne '*') { + $date_range =~ /^(\d\d\d\d)(\d\d)(\d\d)-(\d\d\d\d)(\d\d)(\d\d)$/ + or ThrowUserError('report_invalid_parameter', { name => 'date_range' }); + $query->{start_date} = "$1-$2-$3"; + $query->{end_date} = "$4-$5-$6"; + } + + # product_id + my $product_id = shift @query; + $product_id =~ /^(\d+)$/ + or ThrowUserError('report_invalid_parameter', { name => 'product_id' }); + $query->{product_id} = $1; + + # join + my $join = shift @query; + $join =~ /^(and|or)$/ + or ThrowUserError('report_invalid_parameter', { name => 'join' }); + $query->{join} = $1; + + # fields + my @fields; + foreach my $field (@query) { + $field =~ /^(\d+)([\-\+])$/ + or ThrowUserError('report_invalid_parameter', { name => 'fields' }); + my ($id, $value) = ($1, $2); + my $field_obj = Bugzilla::Field->new($id) + or ThrowUserError('report_invalid_parameter', { name => 'field_id' }); + push @fields, { id => $id, value => $value, name => $field_obj->name }; + } + $query->{fields} = \@fields; + + return $query; +} + +sub _is_active_status_field { + my ($field_name) = @_; + if ($field_name =~ /^cf_status/) { + return !grep { $field_name eq $_ } @$cf_disabled_flags + } + return 0; +} + 1; diff --git a/extensions/BMO/template/en/default/hook/global/user-error-errors.html.tmpl b/extensions/BMO/template/en/default/hook/global/user-error-errors.html.tmpl index 85881aca7..eff0e35cc 100644 --- a/extensions/BMO/template/en/default/hook/global/user-error-errors.html.tmpl +++ b/extensions/BMO/template/en/default/hook/global/user-error-errors.html.tmpl @@ -22,19 +22,15 @@ [% title = "Missing Username" %] You must provide at least one email address to report on. -[% ELSIF error == "user_activity_missing_from_date" %] - [% title = "Missing Date" %] - You must provided the period start date. - -[% ELSIF error == "user_activity_missing_to_date" %] - [% title = "Missing Date" %] - You must provided the period end date. - -[% ELSIF error == "user_activity_invalid_date" %] +[% ELSIF error == "report_invalid_date" %] [% title = "Invalid Date" %] The date '[% date FILTER html %]' is invalid. +[% ELSIF error == "report_invalid_parameter" %] + [% title = "Invalid Parameter" %] + The value for parameter [% name FILTER html %] is invalid. + [% ELSIF error == "invalid_object" %] - Invalid [% object FILTER html %]: "[% value FILTER html %]" + Invalid [% object FILTER html %]: "[% value FILTER html %]" [% END %] diff --git a/extensions/BMO/template/en/default/hook/reports/menu-end.html.tmpl b/extensions/BMO/template/en/default/hook/reports/menu-end.html.tmpl index 846f12767..7c2b2d753 100644 --- a/extensions/BMO/template/en/default/hook/reports/menu-end.html.tmpl +++ b/extensions/BMO/template/en/default/hook/reports/menu-end.html.tmpl @@ -31,6 +31,11 @@ %]page.cgi?id=triage_reports.html">Triage Report</a></strong> - Report on UNCONFIRMED [% terms.bugs %] to assist triage. </li> + <li> + <strong><a href="[% urlbase FILTER none + %]page.cgi?id=release_tracking_report.html">Release Tracking Report</a></strong> - + For triaging release-train flag information. + </li> [% IF user.in_group('editusers') %] <li> <strong><a href="[% urlbase FILTER none diff --git a/extensions/BMO/template/en/default/pages/release_tracking_report.html.tmpl b/extensions/BMO/template/en/default/pages/release_tracking_report.html.tmpl new file mode 100644 index 000000000..ebf3e157c --- /dev/null +++ b/extensions/BMO/template/en/default/pages/release_tracking_report.html.tmpl @@ -0,0 +1,103 @@ +[%# 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. + #%] + +[% INCLUDE global/header.html.tmpl + title = "Release Tracking Report" + style_urls = [ "extensions/BMO/web/styles/reports.css" ] + javascript_urls = [ "extensions/BMO/web/js/release_tracking_report.js" ] +%] + +<noscript> +<h1>JavaScript is required to use this report.</h1> +</noscript> + +<script> +var flags_data = [% flags_json FILTER none %]; +var products_data = [% products_json FILTER none %]; +var fields_data = [% fields_json FILTER none %]; +var default_query = '[% default_query FILTER js %]'; +</script> + +<form action="page.cgi" method="get" onSubmit="return onFormSubmit()"> +<input type="hidden" name="id" value="release_tracking_report.html"> +<input type="hidden" name="q" id="q" value=""> +<table> + +<tr> + <th>Approval:</th> + <td> + Show bugs where + <select id="flag" onChange="onFlagChange()"> + [% FOREACH flag_name = flag_names %] + <option value="[% flag_name FILTER html %]">[% flag_name FILTER html %]</option> + [% END %] + </select> + + was changed to (and is currently) + <select id="flag_value"> + <option value="?">?</option> + <option value="-">-</option> + <option value="+">+</option> + </select> + + between + <select id="range" onChange="serialiseForm()"> + [% FOREACH range = ranges %] + <option value="[% range.value FILTER html %]"> + [% range.label FILTER html %] + </option> + [% END %] + </select> + </td> +</tr> + +<tr> + <th>Status:</th> + <td> + for the product + <select id="product" onChange="onProductChange()"> + </select> + </td> +</tr> + +<tr> + <td> </td> + <td> + <select id="op" onChange="serialiseForm()"> + <option value="and">All selected tracking fields (AND)</option> + <option value="or">Any selected tracking fields (OR)</option> + </select> + [ + <a href="javascript:void(0)" onClick="selectAllFields()">All</a> | + <a href="javascript:void(0)" onClick="selectNoFields()">None</a> + ] + [ + <a href="javascript:void(0)" onClick="invertFields()">Invert</a> + ] + <br> + <span id="tracking_span"> + </span> + </td> +</tr> + +<tr> + <td> </td> + <td colspan="2"> + <input type="submit" value="Search"> + <input type="submit" value="Reset" onClick="onFormReset(); return false"> + <a href="?" id="bookmark">Bookmarkable Link</a> + </td> +</tr> +</table> +</form> + +<p> + <i>"fixed" in the status field checks for the "verified" status as well as "fixed".</i> +</p> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/extensions/BMO/web/js/release_tracking_report.js b/extensions/BMO/web/js/release_tracking_report.js new file mode 100644 index 000000000..840b57df1 --- /dev/null +++ b/extensions/BMO/web/js/release_tracking_report.js @@ -0,0 +1,203 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * This Source Code Form is "Incompatible With Secondary Licenses", as + * defined by the Mozilla Public License, v. 2.0. */ + +var Dom = YAHOO.util.Dom; +var flagEl; +var productEl; +var trackingEl; +var selectedFields; + +// events + +function onFieldToggle(cbEl, id) { + if (cbEl.checked) { + Dom.removeClass('field_' + id + '_td', 'disabled'); + selectedFields['field_' + id] = id; + } else { + Dom.addClass('field_' + id + '_td', 'disabled'); + selectedFields['field_' + id] = false; + } + Dom.get('field_' + id + '_select').disabled = !cbEl.checked; + serialiseForm(); +} + +function onProductChange() { + var product = productEl.value; + var productData = product == '0' ? getFlagByName(flagEl.value) : getProductById(product); + var html = ''; + selectedFields = new Array(); + + if (productData) { + // update status fields + html = '<table>'; + for(var i = 0, l = productData.fields.length; i < l; i++) { + var field = getFieldById(productData.fields[i]); + selectedFields['field_' + field.id] = false; + html += '<tr>' + + '<td>' + + '<input type="checkbox" id="field_' + field.id + '_cb" ' + + 'onClick="onFieldToggle(this,' + field.id + ')">' + + '</td>' + + '<td class="disabled" id="field_' + field.id + '_td">' + + '<label for="field_' + field.id + '_cb">' + + YAHOO.lang.escapeHTML(field.name) + ':</label>' + + '</td>' + + '<td>' + + '<select disabled id="field_' + field.id + '_select">' + + '<option value="+">fixed</option>' + + '<option value="-">not fixed</option>' + + '</select>' + + '</td>' + + '</tr>'; + } + html += '</table>'; + } + trackingEl.innerHTML = html; + serialiseForm(); +} + +function onFlagChange() { + var flag = flagEl.value; + var flagData = getFlagByName(flag); + productEl.options.length = 0; + + if (flagData) { + // update product select + var currentProduct = productEl.value; + productEl.options[0] = new Option('(Any Product)', '0'); + for(var i = 0, l = flagData.products.length; i < l; i++) { + var product = getProductById(flagData.products[i]); + var n = productEl.length; + productEl.options[n] = new Option(product.name, product.id); + productEl.options[n].selected = product.id == currentProduct; + } + } + onProductChange(); +} + +// form + +function selectAllFields() { + for(var i = 0, l = fields_data.length; i < l; i++) { + var cb = Dom.get('field_' + fields_data[i].id + '_cb'); + cb.checked = true; + onFieldToggle(cb, fields_data[i].id); + } + serialiseForm(); +} + +function selectNoFields() { + for(var i = 0, l = fields_data.length; i < l; i++) { + var cb = Dom.get('field_' + fields_data[i].id + '_cb'); + cb.checked = false; + onFieldToggle(cb, fields_data[i].id); + } + serialiseForm(); +} + +function invertFields() { + for(var i = 0, l = fields_data.length; i < l; i++) { + var el = Dom.get('field_' + fields_data[i].id + '_select'); + if (el.value == '+') { + el.options[1].selected = true; + } else { + el.options[0].selected = true; + } + } + serialiseForm(); +} + +function onFormSubmit() { + serialiseForm(); + return true; +} + +function onFormReset() { + deserialiseForm(''); +} + +function serialiseForm() { + var q = flagEl.value + ':' + + Dom.get('flag_value').value + ':' + + Dom.get('range').value + ':' + + productEl.value + ':' + + Dom.get('op').value + ':'; + + for(var id in selectedFields) { + if (selectedFields[id]) { + q += selectedFields[id] + Dom.get(id + '_select').value + ':'; + } + } + + Dom.get('q').value = q; + Dom.get('bookmark').href = 'page.cgi?id=release_tracking_report.html&q=' + + encodeURIComponent(q); +} + +function deserialiseForm(q) { + var parts = q.split(/:/); + selectValue(flagEl, parts[0]); + onFlagChange(); + selectValue(Dom.get('flag_value'), parts[1]); + selectValue(Dom.get('range'), parts[2]); + selectValue(productEl, parts[3]); + onProductChange(); + selectValue(Dom.get('op'), parts[4]); + for(var i = 5, l = parts.length; i < l; i++) { + var part = parts[i]; + if (part.length) { + var value = part.substr(part.length - 1, 1); + var id = part.substr(0, part.length - 1); + var cb = Dom.get('field_' + id + '_cb'); + cb.checked = true; + onFieldToggle(cb, id); + selectValue(Dom.get('field_' + id + '_select'), value); + } + } + serialiseForm(); +} + +// utils + +YAHOO.util.Event.onDOMReady(function() { + flagEl = Dom.get('flag'); + productEl = Dom.get('product'); + trackingEl = Dom.get('tracking_span'); + onFlagChange(); + deserialiseForm(default_query); +}); + +function getFlagByName(name) { + for(var i = 0, l = flags_data.length; i < l; i++) { + if (flags_data[i].name == name) + return flags_data[i]; + } +} + +function getProductById(id) { + for(var i = 0, l = products_data.length; i < l; i++) { + if (products_data[i].id == id) + return products_data[i]; + } +} + +function getFieldById(id) { + for(var i = 0, l = fields_data.length; i < l; i++) { + if (fields_data[i].id == id) + return fields_data[i]; + } +} + +function selectValue(el, value) { + for(var i = 0, l = el.options.length; i < l; i++) { + if (el.options[i].value == value) { + el.options[i].selected = true; + return; + } + } + el.options[0].selected = true; +} diff --git a/extensions/BMO/web/styles/reports.css b/extensions/BMO/web/styles/reports.css index 260a95471..f75f72b8b 100644 --- a/extensions/BMO/web/styles/reports.css +++ b/extensions/BMO/web/styles/reports.css @@ -35,3 +35,7 @@ #report tr:hover { background-color: #ccccff; } + +.disabled { + color: #888888; +} |