diff options
29 files changed, 605 insertions, 179 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d5d4bb6..abe507e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## Releases * Unreleased + - New features: + - (Optional) auto-suggestion of similar nearby problems, + while reporting, to discourage duplicate reports. #2386 - Front end improvements: - Track map state in URL to make sharing links easier. #2242 - Admin improvements: diff --git a/perllib/FixMyStreet/App/Controller/Around.pm b/perllib/FixMyStreet/App/Controller/Around.pm index ec8df4450..a09161494 100644 --- a/perllib/FixMyStreet/App/Controller/Around.pm +++ b/perllib/FixMyStreet/App/Controller/Around.pm @@ -269,7 +269,7 @@ sub map_features : Private { # Allow the cobrand to add in any additional query parameters my $extra_params = $c->cobrand->call_hook('display_location_extra_params'); - my ( $on_map, $nearby, $distance ) = + my ( $on_map, $nearby ) = FixMyStreet::Map::map_features( $c, %$extra, categories => [ keys %{$c->stash->{filter_category}} ], @@ -290,7 +290,6 @@ sub map_features : Private { $c->stash->{pins} = \@pins; $c->stash->{on_map} = $on_map; $c->stash->{around_map} = $nearby; - $c->stash->{distance} = $distance; } =head2 ajax @@ -318,6 +317,18 @@ sub ajax : Path('/ajax') { $c->forward('/reports/ajax', [ 'around/on_map_list_items.html' ]); } +sub nearby : Path { + my ($self, $c) = @_; + + my $states = FixMyStreet::DB::Result::Problem->open_states(); + $c->forward('/report/_nearby_json', [ { + latitude => $c->get_param('latitude'), + longitude => $c->get_param('longitude'), + categories => [ $c->get_param('filter_category') || () ], + states => $states, + } ]); +} + sub location_closest_address : Path('/ajax/closest') { my ( $self, $c ) = @_; $c->res->content_type('application/json; charset=utf-8'); diff --git a/perllib/FixMyStreet/App/Controller/Report.pm b/perllib/FixMyStreet/App/Controller/Report.pm index 12f6ec1d1..e032caa09 100644 --- a/perllib/FixMyStreet/App/Controller/Report.pm +++ b/perllib/FixMyStreet/App/Controller/Report.pm @@ -600,22 +600,40 @@ sub nearby_json :PathPart('nearby.json') :Chained('id') :Args(0) { my ($self, $c) = @_; my $p = $c->stash->{problem}; - my $dist = 1; + $c->forward('_nearby_json', [ { + latitude => $p->latitude, + longitude => $p->longitude, + categories => [ $p->category ], + ids => [ $p->id ], + } ]); +} + +sub _nearby_json :Private { + my ($self, $c, $params) = @_; # This is for the list template, this is a list on that page. $c->stash->{page} = 'report'; - my $extra_params = $c->cobrand->call_hook('display_location_extra_params'); + # distance in metres + my $dist = $c->get_param('distance') || ''; + $dist = 1000 unless $dist =~ /^\d+$/; + $dist = 1000 if $dist > 1000; + $params->{distance} = $dist / 1000; + + my $pin_size = $c->get_param('pin_size') || ''; + $pin_size = 'small' unless $pin_size =~ /^(mini|small|normal|big)$/; + + $params->{extra} = $c->cobrand->call_hook('display_location_extra_params'); + $params->{limit} = 5; + + my $nearby = $c->model('DB::Nearby')->nearby($c, %$params); - my $nearby = $c->model('DB::Nearby')->nearby( - $c, $dist, [ $p->id ], 5, $p->latitude, $p->longitude, [ $p->category ], undef, $extra_params - ); # Want to treat these as if they were on map $nearby = [ map { $_->problem } @$nearby ]; my @pins = map { my $p = $_->pin_data($c, 'around'); [ $p->{latitude}, $p->{longitude}, $p->{colour}, - $p->{id}, $p->{title}, 'small', JSON->false + $p->{id}, $p->{title}, $pin_size, JSON->false ] } @$nearby; diff --git a/perllib/FixMyStreet/Cobrand/Borsetshire.pm b/perllib/FixMyStreet/Cobrand/Borsetshire.pm index 44a4a9162..e721bee0f 100644 --- a/perllib/FixMyStreet/Cobrand/Borsetshire.pm +++ b/perllib/FixMyStreet/Cobrand/Borsetshire.pm @@ -33,4 +33,6 @@ sub bypass_password_checks { 1 } sub enable_category_groups { 1 } +sub suggest_duplicates { 1 } + 1; diff --git a/perllib/FixMyStreet/DB/ResultSet/Nearby.pm b/perllib/FixMyStreet/DB/ResultSet/Nearby.pm index 3d8f87b9f..2ebe309e3 100644 --- a/perllib/FixMyStreet/DB/ResultSet/Nearby.pm +++ b/perllib/FixMyStreet/DB/ResultSet/Nearby.pm @@ -10,34 +10,34 @@ sub to_body { } sub nearby { - my ( $rs, $c, $dist, $ids, $limit, $mid_lat, $mid_lon, $categories, $states, $extra_params, $report_age ) = @_; + my ( $rs, $c, %args ) = @_; - unless ( $states ) { - $states = FixMyStreet::DB::Result::Problem->visible_states(); + unless ( $args{states} ) { + $args{states} = FixMyStreet::DB::Result::Problem->visible_states(); } my $params = { - state => [ keys %$states ], + state => [ keys %{$args{states}} ], }; - $params->{id} = { -not_in => $ids } - if $ids; - $params->{category} = $categories if $categories && @$categories; + $params->{id} = { -not_in => $args{ids} } + if $args{ids}; + $params->{category} = $args{categories} if $args{categories} && @{$args{categories}}; - $params->{$c->stash->{report_age_field}} = { '>=', \"current_timestamp-'$report_age'::interval" } - if $report_age; + $params->{$c->stash->{report_age_field}} = { '>=', \"current_timestamp-'$args{report_age}'::interval" } + if $args{report_age}; FixMyStreet::DB::ResultSet::Problem->non_public_if_possible($params, $c); $rs = $c->cobrand->problems_restriction($rs); # Add in any optional extra query parameters - $params = { %$params, %$extra_params } if $extra_params; + $params = { %$params, %{$args{extra}} } if $args{extra}; my $attrs = { prefetch => 'problem', - bind => [ $mid_lat, $mid_lon, $dist ], + bind => [ $args{latitude}, $args{longitude}, $args{distance} ], order_by => [ 'distance', { -desc => 'created' } ], - rows => $limit, + rows => $args{limit}, }; my @problems = mySociety::Locale::in_gb_locale { $rs->search( $params, $attrs )->all }; diff --git a/perllib/FixMyStreet/Map.pm b/perllib/FixMyStreet/Map.pm index 8ed0c4b37..8b8cfe82c 100644 --- a/perllib/FixMyStreet/Map.pm +++ b/perllib/FixMyStreet/Map.pm @@ -104,26 +104,21 @@ sub map_features { # if show_old_reports is on then there must be old reports $c->stash->{num_old_reports} = 1; } else { - $p{report_age} = undef; - $p{page} = 1; - my $older = $c->cobrand->problems_on_map->around_map( $c, %p ); + my $older = $c->cobrand->problems_on_map->around_map( $c, %p, report_age => undef, page => 1 ); $c->stash->{num_old_reports} = $older->pager->total_entries - $pager->total_entries; } - my $dist = FixMyStreet::Gaze::get_radius_containing_population( $p{latitude}, $p{longitude} ); - # if there are fewer entries than our paging limit on the map then # also return nearby entries for display my $nearby; if (@$on_map < $pager->entries_per_page && $pager->current_page == 1) { - my $limit = 20; - my @ids = map { $_->id } @$on_map; - $nearby = $c->model('DB::Nearby')->nearby( - $c, $dist, \@ids, $limit, @p{"latitude", "longitude", "categories", "states", "extra"}, $report_age - ); + $p{limit} = 20; + $p{ids} = [ map { $_->id } @$on_map ]; + $p{distance} = FixMyStreet::Gaze::get_radius_containing_population( $p{latitude}, $p{longitude} ); + $nearby = $c->model('DB::Nearby')->nearby($c, %p); } - return ( $on_map, $nearby, $dist ); + return ( $on_map, $nearby ); } sub click_to_wgs84 { diff --git a/t/app/controller/around.t b/t/app/controller/around.t index ed29d438c..3f0fff666 100644 --- a/t/app/controller/around.t +++ b/t/app/controller/around.t @@ -359,4 +359,10 @@ subtest 'check map zoom level customisation' => sub { }; }; +subtest 'check nearby lookup' => sub { + my $p = FixMyStreet::DB->resultset("Problem")->search({ external_body => "Pothole-confirmed" })->first; + $mech->get_ok('/around/nearby?latitude=51.754926&longitude=-1.256179&filter_category=Pothole'); + $mech->content_contains('["51.754926","-1.256179","yellow",' . $p->id . ',"Around page Test 1 for ' . $body->id . '","small",false]'); +}; + done_testing(); diff --git a/t/cobrand/restriction.t b/t/cobrand/restriction.t index 9e3018625..63fe326b1 100644 --- a/t/cobrand/restriction.t +++ b/t/cobrand/restriction.t @@ -47,7 +47,7 @@ is($c->model('DB::Problem')->count, 4, 'Four reports in database'); is($cobrand->problems->count, 2, 'Two reports in the right cobrand'); is($cobrand->updates->count, 1, 'One update in the right cobrand'); -my $nearby = $c->model('DB::Nearby')->nearby($c, 5, [], 10, 0.003, 0.004); +my $nearby = $c->model('DB::Nearby')->nearby($c, distance => 5, ids => [], limit => 10, latitude => 0.003, longitude => 0.004); is(@$nearby, 1, 'One report close to the origin point'); done_testing(); diff --git a/t/map/tilma/original.t b/t/map/tilma/original.t index 42cbbd9f2..9a404a2c9 100644 --- a/t/map/tilma/original.t +++ b/t/map/tilma/original.t @@ -93,12 +93,10 @@ for my $test ( $report->update; $c->stash->{report_age_field} = 'lastupdate'; - my ( $on_map, $nearby, $dist ) = - FixMyStreet::Map::map_features($c, bbox => "0,0,0,0"); + my ($on_map, $nearby) = FixMyStreet::Map::map_features($c, bbox => "0,0,0,0"); ok $on_map; ok $nearby; - ok $dist; my $id = $report->id; my $colour = $test->{colour}; diff --git a/templates/web/base/alert/list-ajax.html b/templates/web/base/alert/list-ajax.html index 639af6f07..5da71b58c 100644 --- a/templates/web/base/alert/list-ajax.html +++ b/templates/web/base/alert/list-ajax.html @@ -4,6 +4,6 @@ %] [% END %] -<div id="alerts"> +<div id="alerts" class="js-alert-list"> [% INCLUDE 'alert/_list.html' %] </div> diff --git a/templates/web/base/alert/list.html b/templates/web/base/alert/list.html index 385cd7d32..14215b65d 100644 --- a/templates/web/base/alert/list.html +++ b/templates/web/base/alert/list.html @@ -32,7 +32,7 @@ </div> [% END %] -<form id="alerts" name="alerts" method="post" action="/alert/subscribe"> +<form id="alerts" class="js-alert-list" name="alerts" method="post" action="/alert/subscribe"> [% INCLUDE 'alert/_list.html' %] diff --git a/templates/web/base/around/display_location.html b/templates/web/base/around/display_location.html index 826e70632..6c71ad631 100755 --- a/templates/web/base/around/display_location.html +++ b/templates/web/base/around/display_location.html @@ -33,6 +33,7 @@ SET bodyclass = 'mappage'; SET rss = [ tprintf(loc('Recent local problems, %s', "%s is the site name"), site_name), rss_url ]; + SET extra_js = []; INCLUDE 'header.html', title => loc('Viewing a location') robots => 'noindex,nofollow'; diff --git a/templates/web/base/js/translation_strings.html b/templates/web/base/js/translation_strings.html index af3073f91..9747773d9 100644 --- a/templates/web/base/js/translation_strings.html +++ b/templates/web/base/js/translation_strings.html @@ -41,6 +41,9 @@ fixmystreet.password_minimum_length = [% c.cobrand.password_minimum_length %]; how_to_send: '[% loc('How to send successful reports') | replace("'", "\\'") %]', more_details: '[% loc('Details') | replace("'", "\\'") %]', + this_report: '[% loc('This report') | replace("'", "\\'") %]', + this_is_the_problem: '[% loc('This is the problem') | replace("'", "\\'") %]', + or: '[% loc(' or ') | replace("'", "\\'") %]', geolocation_declined: '[% loc('You declined; please fill in the box above') | replace("'", "\\'") %]', diff --git a/templates/web/base/report/_inspect.html b/templates/web/base/report/_inspect.html index e5094d02e..fa79d9912 100644 --- a/templates/web/base/report/_inspect.html +++ b/templates/web/base/report/_inspect.html @@ -1,3 +1,6 @@ +[% extra_js = [ + version('/js/duplicates.js'), +] -%] [% permissions = c.user.permissions(problem) %] [% second_column = BLOCK -%] <div id="side-inspect"> @@ -120,7 +123,7 @@ <p class="[% "hidden" IF problem.duplicate_of %]">[% loc('Which report is it a duplicate of?') %]</p> <ul class="item-list item-list--inspect-duplicates"> [% IF problem.duplicate_of %] - [% INCLUDE 'report/_item.html' item_extra_class = 'item-list--reports__item--selected' problem = problem.duplicate_of %] + [% INCLUDE 'report/_item_expandable.html' item_extra_class = 'item-list__item--selected' problem = problem.duplicate_of %] <li class="item-list__item"><a class="btn" href="#" id="js-change-duplicate-report">[% loc('Choose another') %]</a></li> [% END %] </ul> @@ -129,7 +132,7 @@ <p><strong>[% loc('Duplicates') %]</strong></p> <ul class="item-list item-list--inspect-duplicates"> [% FOR duplicate IN problem.duplicates %] - [% INCLUDE 'report/_item.html' problem = duplicate %] + [% INCLUDE 'report/_item_expandable.html' problem = duplicate %] [% END %] </ul> [% END %] diff --git a/templates/web/base/report/_item.html b/templates/web/base/report/_item.html index d710f6c81..863b87817 100644 --- a/templates/web/base/report/_item.html +++ b/templates/web/base/report/_item.html @@ -42,40 +42,7 @@ [% IF c.user.has_permission_to('report_inspect', problem.bodies_str_ids) %] <div class="item-list__description">[% problem.detail | html %]</div> [% END %] - <small> - [% IF NOT no_fixed AND problem.is_fixed %] - <span class="item-list__item__state">[% prettify_state('fixed') %]</span> - [% ELSIF NOT no_fixed AND problem.is_closed %] - <span class="item-list__item__state">[% prettify_state('closed') %]</span> - [% ELSIF (c.user.has_permission_to('report_edit_priority', problem.bodies_str_ids) OR c.user.has_permission_to('report_inspect', problem.bodies_str_ids)) AND problem.response_priority %] - <span class="item-list__item__state">[% problem.response_priority.name %]</span> - [% END %] - [%- IF c.cobrand.moniker != 'fixamingata' %] [%# Default: %] - [%- IF problem.days_ago > 0 AND problem.days_ago <= c.cobrand.display_days_ago_threshold %] - [% tprintf( nget('Reported %d day ago', 'Reported %d days ago', problem.days_ago), problem.days_ago ) %] - [%- ELSE %] - [% prettify_dt( problem.confirmed, 1 ) %] - [%- END %] - [%- ELSE %] [%# Swedish cobrand fixamingata: %] - [%- prettify_dt( problem.confirmed) %] - [%- END %] - [%- IF dist %], [% dist %]km[% END %] - [%- IF problem.confirmed != problem.lastupdate AND problem.whensent != problem.lastupdate %], - [%- IF problem.days_ago('lastupdate') > 0 AND problem.days_ago('lastupdate') <= c.cobrand.display_days_ago_threshold %] - [% tprintf( nget('last updated %d day ago', 'last updated %d days ago', problem.days_ago('lastupdate') ), problem.days_ago('lastupdate') ) %] - [%- ELSE %] - [% tprintf(loc('last updated %s'), prettify_dt( problem.lastupdate, 1 ) ) %] - [%- END %] - [%- END %] - [% IF include_sentinfo %] - [% IF c.cobrand.is_council && !c.cobrand.owns_problem( problem ) %] - (sent to [% problem.body %]) - [% ELSIF problem.bodies_str_ids.size > 2 %] [% loc('(sent to all)') %] - [% ELSIF problem.bodies_str_ids.size == 2 %] [% loc('(sent to both)') %] - [% ELSIF problem.bodies_str_ids.size == 0 %] [% loc('(not sent to council)') %] - [% END %] - [% END %] - </small> + <small>[% PROCESS 'report/_item_small.html' %]</small> [% IF c.user.has_permission_to('report_inspect', problem.bodies_str_ids) %] <div class="item-list__item__metadata"> diff --git a/templates/web/base/report/_item_expandable.html b/templates/web/base/report/_item_expandable.html new file mode 100644 index 000000000..6a4fe7191 --- /dev/null +++ b/templates/web/base/report/_item_expandable.html @@ -0,0 +1,55 @@ +[%# + This list item contains a full photo set and description, which is hidden + until the `.expanded` class is added. It is useful for times when you want + to surface the full information about a report, without requiring the user + to visit the dedicated report page. + + Since the photo set includes `<a>` elements of its own, we drop the usual + "wrapper" `<a>` and the associated `.item-list--reports__item` class, to + avoid anchor nesting. +%] + +[% truncated_detail = BLOCK %][% problem.detail | truncate(75, '…') | html_para %][% END ~%] +[% full_detail = BLOCK %][% problem.detail | add_links | html_para %][% END ~%] +[% SET show_more = truncated_detail != full_detail OR problem.photo ~%] + +<li class="[% 'js-expandable ' IF show_more %]item-list__item item-list__item--expandable [% item_extra_class %]" + data-report-id="[% problem.id | html %]" + id="report-[% problem.id | html %]"> + + [% IF problem.photo %] + <a href="[% c.cobrand.base_url_for_report( problem ) %][% problem.url %]" class="item-list__item--expandable__hide-when-expanded"> + <img class="img" height="60" width="90" src="[% problem.photos.first.url_fp %]" alt=""> + </a> + [% END %] + + [% TRY %] + [% PROCESS 'report/_item_heading.html' %] + [% CATCH file %] + <h3> + <a href="[% c.cobrand.base_url_for_report( problem ) %][% problem.url %]"> + [% problem.title | html %] + </a> + </h3> + [% END %] + + <small>[% PROCESS 'report/_item_small.html' %]</small> + + <div class="item-list__item--expandable__hide-when-expanded"> + [% truncated_detail %] + </div> + + [% IF show_more ~%] + <div class="item-list__item--expandable__hide-until-expanded"> + [% full_detail %] + [% INCLUDE 'report/photo.html' object=problem %] + </div> + [% END %] + + <div class="item-list__item--expandable__actions"> + [% IF show_more ~%] + <button class="btn btn--small js-toggle-expansion" data-more="[% loc('Read more') %]" data-less="[% loc('Read less') %]">[% loc('Read more') %]</button> + [% END %] + </div> + +</li> diff --git a/templates/web/base/report/_item_small.html b/templates/web/base/report/_item_small.html new file mode 100644 index 000000000..77fc9f569 --- /dev/null +++ b/templates/web/base/report/_item_small.html @@ -0,0 +1,32 @@ +[% IF NOT no_fixed AND problem.is_fixed %] + <span class="item-list__item__state">[% prettify_state('fixed') %]</span> +[% ELSIF NOT no_fixed AND problem.is_closed %] + <span class="item-list__item__state">[% prettify_state('closed') %]</span> +[% ELSIF (c.user.has_permission_to('report_edit_priority', problem.bodies_str_ids) OR c.user.has_permission_to('report_inspect', problem.bodies_str_ids)) AND problem.response_priority %] + <span class="item-list__item__state">[% problem.response_priority.name %]</span> +[% END %] +[%- IF c.cobrand.moniker != 'fixamingata' %] [%# Default: %] + [%- IF problem.days_ago > 0 AND problem.days_ago <= c.cobrand.display_days_ago_threshold %] + [% tprintf( nget('Reported %d day ago', 'Reported %d days ago', problem.days_ago), problem.days_ago ) %] + [%- ELSE %] + [% prettify_dt( problem.confirmed, 1 ) %] + [%- END %] +[%- ELSE %] [%# Swedish cobrand fixamingata: %] + [%- prettify_dt( problem.confirmed) %] +[%- END %] +[%- IF dist %], [% dist %]km[% END %] +[%- IF problem.confirmed != problem.lastupdate AND problem.whensent != problem.lastupdate %], + [%- IF problem.days_ago('lastupdate') > 0 AND problem.days_ago('lastupdate') <= c.cobrand.display_days_ago_threshold %] + [% tprintf( nget('last updated %d day ago', 'last updated %d days ago', problem.days_ago('lastupdate') ), problem.days_ago('lastupdate') ) %] + [%- ELSE %] + [% tprintf(loc('last updated %s'), prettify_dt( problem.lastupdate, 1 ) ) %] + [%- END %] +[%- END %] +[% IF include_sentinfo %] + [% IF c.cobrand.is_council && !c.cobrand.owns_problem( problem ) %] + (sent to [% problem.body %]) + [% ELSIF problem.bodies_str_ids.size > 2 %] [% loc('(sent to all)') %] + [% ELSIF problem.bodies_str_ids.size == 2 %] [% loc('(sent to both)') %] + [% ELSIF problem.bodies_str_ids.size == 0 %] [% loc('(not sent to council)') %] + [% END %] +[% END %] diff --git a/templates/web/base/report/duplicate-no-updates.html b/templates/web/base/report/duplicate-no-updates.html index 7de2ae042..6cfc85887 100644 --- a/templates/web/base/report/duplicate-no-updates.html +++ b/templates/web/base/report/duplicate-no-updates.html @@ -4,6 +4,6 @@ [% END %] <p>[% loc("This report is a duplicate. Please leave updates on the original report:") %]</p> <ul class="item-list"> - [% INCLUDE 'report/_item.html' item_extra_class = 'item-list__item--with-pin item-list--reports__item--selected' problem = problem.duplicate_of %] + [% INCLUDE 'report/_item.html' item_extra_class = 'item-list__item--with-pin item-list__item--selected' problem = problem.duplicate_of %] </ul> </div> diff --git a/templates/web/base/report/nearby.html b/templates/web/base/report/nearby.html index c64b10d7f..1e0d6cc79 100644 --- a/templates/web/base/report/nearby.html +++ b/templates/web/base/report/nearby.html @@ -1,3 +1,3 @@ [%~ FOREACH problem IN reports ~%] - [%~ INCLUDE 'reports/_list-entry.html' ~%] + [%~ INCLUDE 'report/_item_expandable.html' ~%] [%~ END ~%] diff --git a/templates/web/base/report/new/duplicate_suggestions.html b/templates/web/base/report/new/duplicate_suggestions.html new file mode 100644 index 000000000..582ba58e9 --- /dev/null +++ b/templates/web/base/report/new/duplicate_suggestions.html @@ -0,0 +1,42 @@ +[% IF c.cobrand.suggest_duplicates %] +[% extra_js.push( + version('/js/duplicates.js'), +) -%] +<div id="js-duplicate-reports" class="duplicate-report-suggestions hidden"> + <button class="duplicate-report-suggestions__close js-hide-duplicate-suggestions">[% loc('Close') %]</button> + <h2 class="form-section-heading">[% loc('Already been reported?') %]</h2> + <div class="form-section-description"> + [% IF c.cobrand.is_council %] + <p>[% loc('There are similar problems nearby that we’re already aware of, is one of them yours?') %]</p> + [% ELSE %] + <p>[% loc('We’ve already reported these nearby problems to the council. Is one of them yours?') %]</p> + [% END %] + </div> + + <ul class="item-list"></ul> + <button class="btn btn--block js-hide-duplicate-suggestions">[% loc('Continue – report a new problem') %]</button> +</div> +<div class="js-template-get-updates hidden"> + <div id="alerts" class="get-updates js-alert-list"> + <p id="rznvy_hint"> + [% IF c.user_exists %] + [% loc('Would you like us to notify you when this problem is updated or fixed?') %] + [% ELSE %] + [% loc('If you let us know your email address, we’ll notify you when this problem is updated or fixed.') %] + [% END %] + </p> + <input type="hidden" name="id" disabled> + <input type="hidden" name="token" value="[% csrf_token %]" disabled> + <input type="hidden" name="type" value="updates" disabled> + [% IF c.user_exists %] + <input type="submit" value="[% loc('Get updates') %]" class="btn btn--block" id="alert_email_button"> + [% ELSE %] + <label for="rznvy_input">[% loc('Your email') %]</label> + <div class="form-txt-submit-box"> + <input type="email" class="form-control" name="rznvy" id="rznvy_input" aria-described-by="rznvy_hint" disabled> + <input type="submit" value="[% loc('Get updates') %]" class="btn" id="alert_email_button"> + </div> + [% END %] + </div> +</div> +[% END %] diff --git a/templates/web/base/report/new/fill_in_details.html b/templates/web/base/report/new/fill_in_details.html index 8fa1253da..fa7aabce3 100644 --- a/templates/web/base/report/new/fill_in_details.html +++ b/templates/web/base/report/new/fill_in_details.html @@ -4,6 +4,8 @@ SET bodyclass = ''; SET bodyclass = 'mappage'; SET bodyclass = bodyclass _ " with-notes" IF sidebar_html; + SET extra_js = []; + PROCESS "report/photo-js.html"; PROCESS "maps/${map.type}.html" IF report.used_map; INCLUDE 'header.html', title => loc('Reporting a problem'); %] diff --git a/templates/web/base/report/new/form_report.html b/templates/web/base/report/new/form_report.html index 39e29c723..a5b378641 100644 --- a/templates/web/base/report/new/form_report.html +++ b/templates/web/base/report/new/form_report.html @@ -7,6 +7,7 @@ [% PROCESS "report/new/category_wrapper.html" %] [% TRY %][% PROCESS 'report/new/after_category.html' %][% CATCH file %][% END %] +[% PROCESS "report/new/duplicate_suggestions.html" %] <div class="js-hide-if-invalid-category"> [% TRY %][% PROCESS 'report/new/_form_labels.html' %][% CATCH file %][% END %] diff --git a/web/cobrands/fixmystreet/fixmystreet.js b/web/cobrands/fixmystreet/fixmystreet.js index 5efc0d878..d6d03eac3 100644 --- a/web/cobrands/fixmystreet/fixmystreet.js +++ b/web/cobrands/fixmystreet/fixmystreet.js @@ -974,7 +974,7 @@ $.extend(fixmystreet.set_up, { e.preventDefault(); var form = $('<form/>').attr({ method:'post', action:"/alert/subscribe" }); form.append($('<input name="alert" value="Subscribe me to an email alert" type="hidden" />')); - $('#alerts input[type=text], #alerts input[type=hidden], #alerts input[type=radio]:checked').each(function() { + $(this).closest('.js-alert-list').find('input[type=text], input[type=hidden], input[type=radio]:checked').each(function() { var $v = $(this); $('<input/>').attr({ name:$v.attr('name'), value:$v.val(), type:'hidden' }).appendTo(form); }); @@ -1049,6 +1049,32 @@ $.extend(fixmystreet.set_up, { var set_map_state = true; fixmystreet.back_to_reports_list(e, report_list_url, map_state, set_map_state); }); + }, + + expandable_list_items: function(){ + $(document).on('click', '.js-toggle-expansion', function(e) { + e.preventDefault(); // eg: prevent button from submitting parent form + var $toggle = $(this); + var $parent = $toggle.closest('.js-expandable'); + $parent.toggleClass('expanded'); + $toggle.text($parent.hasClass('expanded') ? $toggle.data('less') : $toggle.data('more')); + }); + + $(document).on('click', '.js-expandable', function(e) { + var $parent = $(this); + // Ignore parents that are already expanded. + if ( ! $parent.hasClass('expanded') ) { + // Ignore clicks on action buttons (clicks on the + // .js-toggle-expansion button will be handled by + // the more specific handler above). + if ( ! $(e.target).is('.item-list__item--expandable__actions *') ) { + e.preventDefault(); + $parent.addClass('expanded'); + var $toggle = $parent.find('.js-toggle-expansion'); + $toggle.text($toggle.data('less')); + } + } + }); } }); diff --git a/web/cobrands/fixmystreet/staff.js b/web/cobrands/fixmystreet/staff.js index fadf18356..a7e0c8896 100644 --- a/web/cobrands/fixmystreet/staff.js +++ b/web/cobrands/fixmystreet/staff.js @@ -1,84 +1,4 @@ fixmystreet.staff_set_up = { - manage_duplicates: function() { - // Deal with changes to report state by inspector/other staff, specifically - // displaying nearby reports if it's changed to 'duplicate'. - function refresh_duplicate_list() { - var report_id = $("#report_inspect_form .js-report-id").text(); - var args = { - filter_category: $("#report_inspect_form select#category").val(), - latitude: $('input[name="latitude"]').val(), - longitude: $('input[name="longitude"]').val() - }; - $("#js-duplicate-reports ul").html('<li class="item-list__item">Loading...</li>'); - var nearby_url = '/report/'+report_id+'/nearby.json'; - $.getJSON(nearby_url, args, function(data) { - var duplicate_of = $("#report_inspect_form [name=duplicate_of]").val(); - var $reports = $(data.reports_list) - .not("[data-report-id="+report_id+"]") - .slice(0, 5); - $reports.filter("[data-report-id="+duplicate_of+"]").addClass("item-list--reports__item--selected"); - - (function() { - var timeout; - $reports.on('mouseenter', function(){ - clearTimeout(timeout); - fixmystreet.maps.markers_highlight(parseInt($(this).data('reportId'), 10)); - }).on('mouseleave', function(){ - timeout = setTimeout(fixmystreet.maps.markers_highlight, 50); - }); - })(); - - $("#js-duplicate-reports ul").empty().prepend($reports); - - $reports.find("a").click(function() { - var report_id = $(this).closest("li").data('reportId'); - $("#report_inspect_form [name=duplicate_of]").val(report_id); - $("#js-duplicate-reports ul li").removeClass("item-list--reports__item--selected"); - $(this).closest("li").addClass("item-list--reports__item--selected"); - return false; - }); - - show_nearby_pins(data, report_id); - }); - } - - function show_nearby_pins(data, report_id) { - var markers = fixmystreet.maps.markers_list( data.pins, true ); - // We're replacing all the features in the markers layer with the - // possible duplicates, but the list of pins from the server doesn't - // include the current report. So we need to extract the feature for - // the current report and include it in the list of features we're - // showing on the layer. - var report_marker = fixmystreet.maps.get_marker_by_id(parseInt(report_id, 10)); - if (report_marker) { - markers.unshift(report_marker); - } - fixmystreet.markers.removeAllFeatures(); - fixmystreet.markers.addFeatures( markers ); - } - - function state_change() { - // The duplicate report list only makes sense when state is 'duplicate' - if ($(this).val() !== "duplicate") { - $("#js-duplicate-reports").addClass("hidden"); - return; - } else { - $("#js-duplicate-reports").removeClass("hidden"); - } - // If this report is already marked as a duplicate of another, then - // there's no need to refresh the list of duplicate reports - var duplicate_of = $("#report_inspect_form [name=duplicate_of]").val(); - if (!!duplicate_of) { - return; - } - - refresh_duplicate_list(); - } - - $("#report_inspect_form").on("change.state", "select#state", state_change); - $("#js-change-duplicate-report").click(refresh_duplicate_list); - }, - action_scheduled_raise_defect: function() { $("#report_inspect_form").find('[name=state]').on('change', function() { if ($(this).val() !== "action scheduled") { diff --git a/web/cobrands/sass/_base.scss b/web/cobrands/sass/_base.scss index 276db90ae..c3cf3cb1b 100644 --- a/web/cobrands/sass/_base.scss +++ b/web/cobrands/sass/_base.scss @@ -530,6 +530,53 @@ ul.error { @include border-radius(0.25em); } +.duplicate-report-suggestions { + position: relative; + + .item-list__item--expandable { + border-top: 1px solid #ddd; + } + + .item-list { + margin: 0 -1em 1em -1em; + border-bottom: 1px solid #ddd; + } +} + +.duplicate-report-suggestions__close { + position: absolute; + right: 0; + top: 0; + display: block; + width: 32px; + height: 0; + padding-top: 32px; + overflow: hidden; + border: none; + background: transparent; + opacity: 0.5; + line-height: 2em; // Make sure line box is taller than text, so text is pushed below hidden overflow. + + &:hover, + &:focus { + opacity: 0.7; + } + + // Doing some gymnastics so we can reuse the existing .btn--close icon. + &:before { + content: ""; + display: block; + width: 16px; + height: 16px; + position: absolute; + top: 8px; + left: 8px; + background-repeat: no-repeat; + background-size: 112px 16px; + @include svg-background-image('/cobrands/fixmystreet/images/button-icons'); + background-position: -32px 0; + } +} /*** LAYOUT ***/ @@ -1194,12 +1241,10 @@ input.final-submit { // that appears in Inspector form, when closing a report as a duplicate. .item-list--inspect-duplicates { border-bottom: none; + background-color: rgba(255, 255, 255, 0.5); - .item-list__item { - background-color: rgba(255, 255, 255, 0.5); - } - - .item-list--reports__item--selected { + .item-list__item--selected, + .js & .item-list__item--selected.expanded:hover { border: 0.25em solid $primary; background-color: #fff; } @@ -1256,6 +1301,92 @@ input.final-submit { } } +.item-list__item--expandable { + @include clearfix(); + margin: 0; + padding: 1em; + + .js & { + cursor: pointer; + + &:hover, + &.hovered { + background-color: $itemlist_item_background_hover; + } + + &.item-list__item--selected, + &.item-list__item--selected:hover, + &.item-list__item--selected.hovered { + cursor: default; + background-color: #fff; + } + } + + h3 { + margin: 0; + } + + .img { + float: $right; + width: 90px; + height: auto; + margin: flip(0 0 0.5em 1em, 0 1em 0.5em 0); + } + + small { + color: #666; + display: block; + margin-top: 0.5em; + } + + p { + line-height: 1.4em; + margin-top: 0.5em; + } + + &.expanded { + .js & { + cursor: default; + + &:hover { + background-color: $itemlist_item_background; + } + } + } +} + +.item-list__item--expandable__actions { + @include flex-container(); + + & > * { + @include flex(1); // Force equal width children + } +} + +.item-list__item--expandable__hide-until-expanded { + display: none; + + .expanded & { + display: block; + } +} + +.item-list__item--expandable__hide-when-expanded { + .expanded & { + display: none; + } +} + +.item-list__item .get-updates { + margin: 0 -1em -1em -1em; + padding: 1em; + background-color: $itemlist_item_background_hover; + + p, label { + margin: 0 0 0.5em 0; + } +} + .problem-header .update-img, .item-list .update-img { float: $right; @@ -1768,6 +1899,23 @@ img.pin { height: 10em; // eg: at the top of individual report pages } +#map_sidebar { + // Chrome/Safari count padding-bottom as part of the scrollable content in + // an overflow:scroll element (technically in contravention of the spec), + // whereas Firefox/IE render the padding outside the scrollable area. + // In desktop mode, we need padding at the bottom of the sidebar, to stop + // .shadow-wrap from obscuring content at the bottom of the sidebar. So we + // use an :after pseudo-element instead of padding. + // In mobile mode, the extra space here still helps differentiate the page + // content on report/reporting pages, from the nav immediately below. + // https://bugzilla.mozilla.org/show_bug.cgi?id=748518 + &:after { + content: ""; + display: block; + height: 4em; + } +} + // When you're in the reporting flow on mobile, we hide the site-header // and make the map full screen to reduce distractions. JavaScript also // tweaks the text content of some of the map-related elements, to make diff --git a/web/cobrands/sass/_layout.scss b/web/cobrands/sass/_layout.scss index 8735da4f5..7d2b99c9b 100644 --- a/web/cobrands/sass/_layout.scss +++ b/web/cobrands/sass/_layout.scss @@ -268,19 +268,6 @@ body.mappage.admin { width: $mappage-sidebar-width--medium + $mappage-actions-width--medium; } } - - // Chrome/Safari count padding-bottom as part of the scrollable content in - // an overflow:scroll element (technically in contravention of the spec), - // whereas Firefox/IE render the padding outside the scrollable area. - // We need padding at the bottom of the sidebar, to stop .shadow-wrap from - // obscuring content at the bottom of the sidebar. So we use an :after - // pseudo-element instead of padding. - // https://bugzilla.mozilla.org/show_bug.cgi?id=748518 - &:after { - content: ""; - display: block; - height: 4em; - } } // This goes inside #map_sidebar, and establishes a flex container allowing diff --git a/web/cobrands/sass/_report_list_pins.scss b/web/cobrands/sass/_report_list_pins.scss index 55ef1cf56..f6fcb46f9 100644 --- a/web/cobrands/sass/_report_list_pins.scss +++ b/web/cobrands/sass/_report_list_pins.scss @@ -27,7 +27,7 @@ $pin_prefix: '/i/' !default; background-image: url('#{$pin_prefix}pin-orange-small.png'); } } -.item-list--reports__item--selected { +.item-list__item--selected { background: $base_bg; a, a:hover, a:focus { diff --git a/web/js/duplicates.js b/web/js/duplicates.js new file mode 100644 index 000000000..4ed54846c --- /dev/null +++ b/web/js/duplicates.js @@ -0,0 +1,206 @@ +$(function() { + + // Store a reference to the "duplicate" report pins so we can + // quickly remove them when we’re finished showing duplicates. + var current_duplicate_markers; + + // Report ID will be available on report inspect page, + // but undefined on new report page. + var report_id = $("#report_inspect_form .js-report-id").text() || undefined; + + function refresh_duplicate_list() { + // This function will return a jQuery Promise, so callbacks can be + // hooked onto it, once the ajax request as completed. + var dfd = $.Deferred(); + + var nearby_url; + var url_params = { + filter_category: $('select[name="category"]').val(), + latitude: $('input[name="latitude"]').val(), + longitude: $('input[name="longitude"]').val() + }; + + if ( report_id ) { + nearby_url = '/report/' + report_id + '/nearby.json'; + url_params.distance = 1000; // Inspectors might want to see reports fairly far away (1000 metres) + url_params.pin_size = 'small'; // How it's always been + } else { + nearby_url = '/around/nearby'; + url_params.distance = 250; // Only want to bother public with very nearby reports (250 metres) + url_params.pin_size = 'normal'; + } + + $.ajax({ + url: nearby_url, + data: url_params, + dataType: 'json' + }).done(function(response) { + if ( response.pins.length ){ + render_duplicate_list(response); + render_duplicate_pins(response); + } else { + remove_duplicate_pins(); + remove_duplicate_list(); + } + dfd.resolve(); + }).fail(function(){ + remove_duplicate_pins(); + remove_duplicate_list(); + dfd.reject(); + }); + + return dfd.promise(); + } + + function render_duplicate_list(api_response) { + var $reports = $( api_response.reports_list ); + + var duplicate_of = $('#report_inspect_form [name="duplicate_of"]').val(); + if ( duplicate_of ) { + $reports.filter('[data-report-id="' + duplicate_of + '"]') + .addClass("item-list__item--selected"); + } + + $("#js-duplicate-reports ul").empty().prepend( $reports ); + fixmystreet.set_up.fancybox_images(); + + $('#js-duplicate-reports').hide().removeClass('hidden').slideDown(); + if ( $('#problem_form').length ) { + $('.js-hide-if-invalid-category').slideUp(); + } + + // Highlight map pin when hovering associated list item. + var timeout; + $reports.on('mouseenter', function(){ + var id = parseInt( $(this).data('reportId'), 10 ); + clearTimeout( timeout ); + fixmystreet.maps.markers_highlight( id ); + }).on('mouseleave', function(){ + timeout = setTimeout( fixmystreet.maps.markers_highlight, 50 ); + }); + + // Add a "select this report" button, when on the report inspect form. + if ( $('#report_inspect_form').length ) { + $reports.each(function(){ + var $button = $('<button>').addClass('btn btn--small btn--primary'); + $button.text(translation_strings.this_report); + $button.on('click', function(e) { + e.preventDefault(); // Prevent button from submitting parent form + var report_id = $(this).closest('li').data('reportId'); + $('#report_inspect_form [name="duplicate_of"]').val(report_id); + $(this).closest('li') + .addClass('item-list__item--selected') + .siblings('.item-list__item--selected') + .removeClass('item-list__item--selected'); + }); + $(this).find('.item-list__item--expandable__actions').append($button); + }); + } + + // Add a "track this report" button when on the regular reporting form. + if ( $('#problem_form').length ) { + $reports.each(function() { + var $li = $(this); + var id = parseInt( $li.data('reportId'), 10 ); + var alert_url = '/alert/subscribe?id=' + encodeURIComponent(id); + var $button = $('<a>').addClass('btn btn--small btn--primary'); + $button.text(translation_strings.this_is_the_problem); + $button.attr('href', alert_url); + $button.on('click', function(e){ + e.preventDefault(); + var $div = $('.js-template-get-updates > div').clone(); + $div.find('input[name="id"]').val(id); + $div.find('input[disabled]').prop('disabled', false); + $div.hide().appendTo($li).slideDown(250, function(){ + $div.find('input[type="email"]').focus(); + }); + $li.find('.item-list__item--expandable__actions').slideUp(250); + $li.removeClass('js-expandable'); + $li.addClass('item-list__item--selected'); + }); + $li.find('.item-list__item--expandable__actions').append($button); + }); + } + } + + function render_duplicate_pins(api_response) { + var markers = fixmystreet.maps.markers_list( api_response.pins, true ); + fixmystreet.markers.removeFeatures( current_duplicate_markers ); + fixmystreet.markers.addFeatures( markers ); + current_duplicate_markers = markers; + } + + function remove_duplicate_list(cb) { + var animations = []; + + animations.push( $.Deferred() ); + $('#js-duplicate-reports').slideUp(function(){ + $(this).addClass('hidden'); + $(this).find('ul').empty(); + animations[0].resolve(); + }); + if ( $('#problem_form').length ) { + animations.push( $.Deferred() ); + $('.js-hide-if-invalid-category').slideDown(function(){ + animations[1].resolve(); + }); + } + + $.when.apply(this, animations).then(cb); + } + + function remove_duplicate_pins() { + fixmystreet.markers.removeFeatures( current_duplicate_markers ); + } + + function inspect_form_state_change() { + // The duplicate report list only makes sense when state is 'duplicate' + if ($(this).val() !== "duplicate") { + $("#js-duplicate-reports").addClass("hidden"); + return; + } else { + $("#js-duplicate-reports").removeClass("hidden"); + } + // If this report is already marked as a duplicate of another, then + // there's no need to refresh the list of duplicate reports + var duplicate_of = $("#report_inspect_form [name=duplicate_of]").val(); + if (!!duplicate_of) { + return; + } + refresh_duplicate_list(); + } + + var category_changing = false; + function problem_form_category_change() { + // Annoyingly this event seems to fire a few times in quick succession, + // so set a flag to avoid multiple overlapping refreshes. + if (category_changing) { return; } + category_changing = true; + + refresh_duplicate_list().always(function(){ + // Wait an extra second until we allow another reload. + setTimeout(function(){ + category_changing = false; + }, 1000); + }); + } + + // Want to show potential duplicates when a regular user starts a new + // report, or changes the category/location of a partial report. + $("#problem_form").on("change.category", "select#form_category", problem_form_category_change); + + // Want to show duplicates when an inspector sets a report’s state to "duplicate". + $("#report_inspect_form").on("change.state", "select#state", inspect_form_state_change); + + // Also want to give inspectors a way to select a *new* duplicate report. + $("#js-change-duplicate-report").click(refresh_duplicate_list); + + $('.js-hide-duplicate-suggestions').on('click', function(e){ + e.preventDefault(); + remove_duplicate_pins(); + remove_duplicate_list(function(){ + $('#form_title').focus(); + }); + }); + +}); diff --git a/web/js/map-OpenLayers.js b/web/js/map-OpenLayers.js index fdfeed314..984f4b098 100644 --- a/web/js/map-OpenLayers.js +++ b/web/js/map-OpenLayers.js @@ -381,15 +381,15 @@ $.extend(fixmystreet.utils, { function sidebar_highlight(problem_id) { if (typeof problem_id !== 'undefined') { - var $a = $('.item-list--reports a[href$="/' + problem_id + '"]'); - $a.parent().addClass('hovered'); + var $li = $('[data-report-id="' + problem_id + '"]'); + $li.addClass('hovered'); } else { - $('.item-list--reports .hovered').removeClass('hovered'); + $('.item-list .hovered').removeClass('hovered'); } } function marker_click(problem_id, evt) { - var $a = $('.item-list--reports a[href$="/' + problem_id + '"]'); + var $a = $('.item-list a[href$="/' + problem_id + '"]'); if (!$a[0]) { return; } |