diff options
31 files changed, 997 insertions, 190 deletions
diff --git a/.cypress/cypress/integration/buckinghamshire.js b/.cypress/cypress/integration/buckinghamshire.js index d5b14100a..6e87da624 100644 --- a/.cypress/cypress/integration/buckinghamshire.js +++ b/.cypress/cypress/integration/buckinghamshire.js @@ -5,6 +5,7 @@ describe('flytipping', function() { cy.fixture('roads.xml'); cy.route('**mapserver/bucks*Whole_Street*', 'fixture:roads.xml').as('roads-layer'); cy.route('/report/new/ajax*').as('report-ajax'); + cy.route('/around/nearby*').as('around-ajax'); cy.visit('http://buckinghamshire.localhost:3001/'); cy.contains('Buckinghamshire'); cy.get('[name=pc]').type('SL9 0NX'); @@ -51,4 +52,17 @@ describe('flytipping', function() { cy.get('[name=site_code]').should('have.value', '7300268'); }); + it('uses the label "Full name" for the name field', function() { + cy.get('#map_box').click(290, 307); + cy.wait('@report-ajax'); + cy.get('select:eq(4)').select('Flytipping'); + cy.wait('@around-ajax'); + cy.get('.js-hide-duplicate-suggestions:first').should('be.visible').click(); + + cy.get('[name=title]').type('Title'); + cy.get('[name=detail]').type('Detail'); + cy.get('.js-new-report-user-show').click(); + cy.get('label[for=form_name]').should('contain', 'Full name'); + }); + }); diff --git a/CHANGELOG.md b/CHANGELOG.md index fd60691d6..64b178919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ - Allow report as another user with only name. - Allow staff users to sign other people up for alerts. - Group categories on body page. #2850 + - Add admin UI for managing web manifest themes. #2792 - Add a new "staff" contact state. - New features: - Categories can be listed under more than one group #2475 @@ -70,6 +71,7 @@ - Inconsistent display of mark private checkbox for staff users - Clear user categories when staff access is removed. #2815 - Only trigger one change event on initial popstate. + - Fix error when hiding a user's updates with no confirmed updates. #2898 - Development improvements: - Upgrade the underlying framework and a number of other packages. #2473 - Add feature cobrand helper function. @@ -149,6 +151,7 @@ - Make front page cache time configurable. - Better working of /fakemapit/ under https. - Improve Open311 error output on failing GET requests. + - Optionally log failed geocoder searches. - Backwards incompatible changes: - If you wish the default for the showname checkbox to be checked, add `sub default_show_name { 1 }` to your cobrand file. diff --git a/bin/fixmystreet.com/buckinghamshire-flytipping b/bin/fixmystreet.com/buckinghamshire-flytipping index 27548be88..70a37ea34 100755 --- a/bin/fixmystreet.com/buckinghamshire-flytipping +++ b/bin/fixmystreet.com/buckinghamshire-flytipping @@ -34,7 +34,7 @@ my $body = FixMyStreet::DB->resultset("Body")->for_areas(BUCKS_AREA_ID)->first; die "Could not find Bucks body" unless $body; my @districts = FixMyStreet::DB->resultset("Body")->for_areas(DISTRICT_IDS)->all; -my @district_ids = map { $_->id } @districts; +my @district_ids = map { $_->id } grep { $_->name ne 'TfL' } @districts; die "Did not find all districts" unless @district_ids == 4; find_problems(TIME_OPEN_ALERT, TIME_OPEN, 'Auto-closure', 1); diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index c51eb3c2e..ed1962744 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -1,205 +1,245 @@ GEM remote: https://rubygems.org/ specs: - activesupport (4.2.8) - i18n (~> 0.7) + activesupport (6.0.2.1) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) minitest (~> 5.1) - thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) - addressable (2.5.1) - public_suffix (~> 2.0, >= 2.0.2) + zeitwerk (~> 2.2) + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) coffee-script (2.4.1) coffee-script-source execjs - coffee-script-source (1.12.2) + coffee-script-source (1.11.1) colorator (1.1.0) + commonmarker (0.17.13) + ruby-enum (~> 0.5) + concurrent-ruby (1.1.6) + dnsruby (1.61.3) + addressable (~> 2.5) em-websocket (0.5.1) eventmachine (>= 0.12.9) http_parser.rb (~> 0.6.0) - ethon (0.10.1) + ethon (0.12.0) ffi (>= 1.3.0) - eventmachine (1.2.5) + eventmachine (1.2.7) execjs (2.7.0) - faraday (0.12.1) + faraday (1.0.0) multipart-post (>= 1.2, < 3) - ffi (1.9.18) + ffi (1.12.2) forwardable-extended (2.6.0) - gemoji (3.0.0) - github-pages (145) - activesupport (= 4.2.8) - github-pages-health-check (= 1.3.4) - jekyll (= 3.4.5) - jekyll-avatar (= 0.4.2) - jekyll-coffeescript (= 1.0.1) + gemoji (3.0.1) + github-pages (204) + github-pages-health-check (= 1.16.1) + jekyll (= 3.8.5) + jekyll-avatar (= 0.7.0) + jekyll-coffeescript (= 1.1.1) + jekyll-commonmark-ghpages (= 0.1.6) jekyll-default-layout (= 0.1.4) - jekyll-feed (= 0.9.2) - jekyll-gist (= 1.4.0) - jekyll-github-metadata (= 2.5.1) - jekyll-mentions (= 1.2.0) - jekyll-optional-front-matter (= 0.2.0) + jekyll-feed (= 0.13.0) + jekyll-gist (= 1.5.0) + jekyll-github-metadata (= 2.13.0) + jekyll-mentions (= 1.5.1) + jekyll-optional-front-matter (= 0.3.2) jekyll-paginate (= 1.1.0) - jekyll-readme-index (= 0.1.0) - jekyll-redirect-from (= 0.12.1) - jekyll-relative-links (= 0.4.1) - jekyll-sass-converter (= 1.5.0) - jekyll-seo-tag (= 2.2.3) - jekyll-sitemap (= 1.0.0) - jekyll-swiss (= 0.4.0) - jekyll-theme-architect (= 0.0.4) - jekyll-theme-cayman (= 0.0.4) - jekyll-theme-dinky (= 0.0.4) - jekyll-theme-hacker (= 0.0.4) - jekyll-theme-leap-day (= 0.0.4) - jekyll-theme-merlot (= 0.0.4) - jekyll-theme-midnight (= 0.0.4) - jekyll-theme-minimal (= 0.0.4) - jekyll-theme-modernist (= 0.0.4) - jekyll-theme-primer (= 0.3.1) - jekyll-theme-slate (= 0.0.4) - jekyll-theme-tactile (= 0.0.4) - jekyll-theme-time-machine (= 0.0.4) - jekyll-titles-from-headings (= 0.2.0) - jemoji (= 0.8.0) - kramdown (= 1.13.2) - liquid (= 3.0.6) - listen (= 3.0.6) + jekyll-readme-index (= 0.3.0) + jekyll-redirect-from (= 0.15.0) + jekyll-relative-links (= 0.6.1) + jekyll-remote-theme (= 0.4.1) + jekyll-sass-converter (= 1.5.2) + jekyll-seo-tag (= 2.6.1) + jekyll-sitemap (= 1.4.0) + jekyll-swiss (= 1.0.0) + jekyll-theme-architect (= 0.1.1) + jekyll-theme-cayman (= 0.1.1) + jekyll-theme-dinky (= 0.1.1) + jekyll-theme-hacker (= 0.1.1) + jekyll-theme-leap-day (= 0.1.1) + jekyll-theme-merlot (= 0.1.1) + jekyll-theme-midnight (= 0.1.1) + jekyll-theme-minimal (= 0.1.1) + jekyll-theme-modernist (= 0.1.1) + jekyll-theme-primer (= 0.5.4) + jekyll-theme-slate (= 0.1.1) + jekyll-theme-tactile (= 0.1.1) + jekyll-theme-time-machine (= 0.1.1) + jekyll-titles-from-headings (= 0.5.3) + jemoji (= 0.11.1) + kramdown (= 1.17.0) + liquid (= 4.0.3) mercenary (~> 0.3) - minima (= 2.1.1) - rouge (= 1.11.1) + minima (= 2.5.1) + nokogiri (>= 1.10.4, < 2.0) + rouge (= 3.13.0) terminal-table (~> 1.4) - github-pages-health-check (1.3.4) + github-pages-health-check (1.16.1) addressable (~> 2.3) - net-dns (~> 0.8) + dnsruby (~> 1.60) octokit (~> 4.0) - public_suffix (~> 2.0) - typhoeus (~> 0.7) - html-pipeline (2.6.0) + public_suffix (~> 3.0) + typhoeus (~> 1.3) + html-pipeline (2.12.3) activesupport (>= 2) nokogiri (>= 1.4) http_parser.rb (0.6.0) - i18n (0.8.6) - jekyll (3.4.5) + i18n (0.9.5) + concurrent-ruby (~> 1.0) + jekyll (3.8.5) addressable (~> 2.4) colorator (~> 1.0) + em-websocket (~> 0.5) + i18n (~> 0.7) jekyll-sass-converter (~> 1.0) - jekyll-watch (~> 1.1) - kramdown (~> 1.3) - liquid (~> 3.0) + jekyll-watch (~> 2.0) + kramdown (~> 1.14) + liquid (~> 4.0) mercenary (~> 0.3.3) pathutil (~> 0.9) - rouge (~> 1.7) + rouge (>= 1.7, < 4) safe_yaml (~> 1.0) - jekyll-avatar (0.4.2) - jekyll (~> 3.0) - jekyll-coffeescript (1.0.1) + jekyll-avatar (0.7.0) + jekyll (>= 3.0, < 5.0) + jekyll-coffeescript (1.1.1) coffee-script (~> 2.2) + coffee-script-source (~> 1.11.1) + jekyll-commonmark (1.3.1) + commonmarker (~> 0.14) + jekyll (>= 3.7, < 5.0) + jekyll-commonmark-ghpages (0.1.6) + commonmarker (~> 0.17.6) + jekyll-commonmark (~> 1.2) + rouge (>= 2.0, < 4.0) jekyll-default-layout (0.1.4) jekyll (~> 3.0) - jekyll-feed (0.9.2) - jekyll (~> 3.3) - jekyll-gist (1.4.0) + jekyll-feed (0.13.0) + jekyll (>= 3.7, < 5.0) + jekyll-gist (1.5.0) octokit (~> 4.2) - jekyll-github-metadata (2.5.1) - jekyll (~> 3.1) + jekyll-github-metadata (2.13.0) + jekyll (>= 3.4, < 5.0) octokit (~> 4.0, != 4.4.0) jekyll-livereload (0.2.2) em-websocket (~> 0.5) jekyll (~> 3.0) - jekyll-mentions (1.2.0) - activesupport (~> 4.0) + jekyll-mentions (1.5.1) html-pipeline (~> 2.3) - jekyll (~> 3.0) - jekyll-optional-front-matter (0.2.0) - jekyll (~> 3.0) + jekyll (>= 3.7, < 5.0) + jekyll-optional-front-matter (0.3.2) + jekyll (>= 3.0, < 5.0) jekyll-paginate (1.1.0) - jekyll-readme-index (0.1.0) - jekyll (~> 3.0) - jekyll-redirect-from (0.12.1) - jekyll (~> 3.3) - jekyll-relative-links (0.4.1) - jekyll (~> 3.3) - jekyll-sass-converter (1.5.0) + jekyll-readme-index (0.3.0) + jekyll (>= 3.0, < 5.0) + jekyll-redirect-from (0.15.0) + jekyll (>= 3.3, < 5.0) + jekyll-relative-links (0.6.1) + jekyll (>= 3.3, < 5.0) + jekyll-remote-theme (0.4.1) + addressable (~> 2.0) + jekyll (>= 3.5, < 5.0) + rubyzip (>= 1.3.0) + jekyll-sass-converter (1.5.2) sass (~> 3.4) - jekyll-seo-tag (2.2.3) - jekyll (~> 3.3) - jekyll-sitemap (1.0.0) - jekyll (~> 3.3) - jekyll-swiss (0.4.0) - jekyll-theme-architect (0.0.4) - jekyll (~> 3.3) - jekyll-theme-cayman (0.0.4) - jekyll (~> 3.3) - jekyll-theme-dinky (0.0.4) - jekyll (~> 3.3) - jekyll-theme-hacker (0.0.4) - jekyll (~> 3.3) - jekyll-theme-leap-day (0.0.4) - jekyll (~> 3.3) - jekyll-theme-merlot (0.0.4) - jekyll (~> 3.3) - jekyll-theme-midnight (0.0.4) - jekyll (~> 3.3) - jekyll-theme-minimal (0.0.4) - jekyll (~> 3.3) - jekyll-theme-modernist (0.0.4) - jekyll (~> 3.3) - jekyll-theme-primer (0.3.1) - jekyll (~> 3.3) - jekyll-theme-slate (0.0.4) - jekyll (~> 3.3) - jekyll-theme-tactile (0.0.4) - jekyll (~> 3.3) - jekyll-theme-time-machine (0.0.4) - jekyll (~> 3.3) - jekyll-titles-from-headings (0.2.0) - jekyll (~> 3.3) - jekyll-watch (1.5.0) - listen (~> 3.0, < 3.1) - jemoji (0.8.0) - activesupport (~> 4.0) + jekyll-seo-tag (2.6.1) + jekyll (>= 3.3, < 5.0) + jekyll-sitemap (1.4.0) + jekyll (>= 3.7, < 5.0) + jekyll-swiss (1.0.0) + jekyll-theme-architect (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-cayman (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-dinky (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-hacker (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-leap-day (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-merlot (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-midnight (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-minimal (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-modernist (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-primer (0.5.4) + jekyll (> 3.5, < 5.0) + jekyll-github-metadata (~> 2.9) + jekyll-seo-tag (~> 2.0) + jekyll-theme-slate (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-tactile (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-theme-time-machine (0.1.1) + jekyll (~> 3.5) + jekyll-seo-tag (~> 2.0) + jekyll-titles-from-headings (0.5.3) + jekyll (>= 3.3, < 5.0) + jekyll-watch (2.2.1) + listen (~> 3.0) + jemoji (0.11.1) gemoji (~> 3.0) html-pipeline (~> 2.2) - jekyll (>= 3.0) - kramdown (1.13.2) - liquid (3.0.6) - listen (3.0.6) - rb-fsevent (>= 0.9.3) - rb-inotify (>= 0.9.7) + jekyll (>= 3.0, < 5.0) + kramdown (1.17.0) + liquid (4.0.3) + listen (3.2.1) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) mercenary (0.3.6) - mini_portile2 (2.2.0) - minima (2.1.1) - jekyll (~> 3.3) - minitest (5.10.2) - multipart-post (2.0.0) - net-dns (0.8.0) - nokogiri (1.8.0) - mini_portile2 (~> 2.2.0) - octokit (4.7.0) + mini_portile2 (2.4.0) + minima (2.5.1) + jekyll (>= 3.5, < 5.0) + jekyll-feed (~> 0.9) + jekyll-seo-tag (~> 2.1) + minitest (5.14.0) + multipart-post (2.1.1) + nokogiri (1.10.8) + mini_portile2 (~> 2.4.0) + octokit (4.16.0) + faraday (>= 0.9) sawyer (~> 0.8.0, >= 0.5.3) - pathutil (0.14.0) + pathutil (0.16.2) forwardable-extended (~> 2.6) - public_suffix (2.0.5) - rb-fsevent (0.10.2) - rb-inotify (0.9.10) - ffi (>= 0.5.0, < 2) - rouge (1.11.1) - safe_yaml (1.0.4) - sass (3.5.1) + public_suffix (3.1.1) + rb-fsevent (0.10.3) + rb-inotify (0.10.1) + ffi (~> 1.0) + rouge (3.13.0) + ruby-enum (0.7.2) + i18n + rubyzip (2.2.0) + safe_yaml (1.0.5) + sass (3.7.4) sass-listen (~> 4.0.0) sass-listen (4.0.0) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - sawyer (0.8.1) - addressable (>= 2.3.5, < 2.6) - faraday (~> 0.8, < 1.0) + sawyer (0.8.2) + addressable (>= 2.3.5) + faraday (> 0.8, < 2.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) thread_safe (0.3.6) - typhoeus (0.8.0) - ethon (>= 0.8.0) - tzinfo (1.2.3) + typhoeus (1.3.1) + ethon (>= 0.9.0) + tzinfo (1.2.6) thread_safe (~> 0.1) - unicode-display_width (1.3.0) + unicode-display_width (1.6.1) + zeitwerk (2.2.2) PLATFORMS ruby @@ -209,4 +249,4 @@ DEPENDENCIES jekyll-livereload BUNDLED WITH - 1.13.7 + 1.16.1 diff --git a/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm b/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm new file mode 100644 index 000000000..9e3bdc33e --- /dev/null +++ b/perllib/FixMyStreet/App/Controller/Admin/ManifestTheme.pm @@ -0,0 +1,99 @@ +package FixMyStreet::App::Controller::Admin::ManifestTheme; +use Moose; +use namespace::autoclean; + +BEGIN { extends 'Catalyst::Controller'; } + +use FixMyStreet::App::Form::ManifestTheme; + +sub auto :Private { + my ($self, $c) = @_; + + if ( $c->cobrand->moniker eq 'fixmystreet' ) { + $c->stash(rs => $c->model('DB::ManifestTheme')->search_rs({}), show_all => 1); + } else { + $c->stash(rs => $c->model('DB::ManifestTheme')->search_rs({ cobrand => $c->cobrand->moniker })); + } +} + +sub index :Path :Args(0) { + my ( $self, $c ) = @_; + + unless ( $c->stash->{show_all} ) { + if ( $c->stash->{rs}->count ) { + $c->res->redirect($c->uri_for($self->action_for('edit'), [ $c->stash->{rs}->first->cobrand ])); + } else { + $c->res->redirect($c->uri_for($self->action_for('create'))); + } + $c->detach; + } +} + +sub item :PathPart('admin/manifesttheme') :Chained :CaptureArgs(1) { + my ($self, $c, $cobrand) = @_; + + my $obj = $c->stash->{rs}->find({ cobrand => $cobrand }) + or $c->detach('/page_error_404_not_found', []); + $c->stash(obj => $obj); +} + +sub edit :PathPart('') :Chained('item') :Args(0) { + my ($self, $c) = @_; + + my $form = $self->form($c, $c->stash->{obj}); + + # We need to do this after form processing, in case a form POST has deleted + # an icon. + $c->stash->{editing_manifest_theme} = $c->forward('/offline/_find_manifest_theme', [ $c->stash->{obj}->cobrand, 1 ]); + + return $form; +} + + +sub create :Local :Args(0) { + my ($self, $c) = @_; + + unless ( $c->stash->{show_all} || $c->stash->{rs}->count == 0) { + $c->res->redirect($c->uri_for($self->action_for('edit'), [ $c->stash->{rs}->first->cobrand ])); + $c->detach; + } + + my $theme = $c->stash->{rs}->new_result({}); + return $self->form($c, $theme); +} + +sub form { + my ($self, $c, $theme) = @_; + + if ($c->get_param('delete_theme')) { + $c->forward('_delete_all_manifest_icons'); + $c->forward('/offline/_clear_manifest_theme_cache', [ $theme->cobrand ]); + $theme->delete; + $c->forward('/admin/log_edit', [ $theme->id, 'manifesttheme', 'delete' ]); + $c->response->redirect($c->uri_for($self->action_for('index'))); + $c->detach; + } + + my $action = $theme->in_storage ? 'edit' : 'add'; + my $form = FixMyStreet::App::Form::ManifestTheme->new( cobrand => $c->cobrand->moniker ); + $c->stash(template => 'admin/manifesttheme/form.html', form => $form); + my $params = $c->req->params; + $params->{icon} = $c->req->upload('icon') if $params->{icon}; + $form->process(item => $theme, params => $params); + return unless $form->validated; + + $c->forward('/admin/log_edit', [ $theme->id, 'manifesttheme', $action ]); + $c->forward('/offline/_clear_manifest_theme_cache', [ $theme->cobrand ]); + $c->response->redirect($c->uri_for($self->action_for('index'))); +} + +sub _delete_all_manifest_icons :Private { + my ($self, $c) = @_; + + my $theme = $c->forward('/offline/_find_manifest_theme', [ $c->stash->{obj}->cobrand, 1 ]); + foreach my $icon ( @{ $theme->{icons} } ) { + unlink FixMyStreet->path_to('web', $icon->{src}); + } +} + +1; diff --git a/perllib/FixMyStreet/App/Controller/Location.pm b/perllib/FixMyStreet/App/Controller/Location.pm index 8d5b0b147..81c2c33fc 100644 --- a/perllib/FixMyStreet/App/Controller/Location.pm +++ b/perllib/FixMyStreet/App/Controller/Location.pm @@ -6,6 +6,7 @@ BEGIN {extends 'Catalyst::Controller'; } use Encode; use FixMyStreet::Geocode; +use Try::Tiny; use Utils; =head1 NAME @@ -107,6 +108,25 @@ sub determine_location_from_pc : Private { # pass errors back to the template $c->stash->{location_error_pc_lookup} = 1; $c->stash->{location_error} = $error; + + # Log failure in a log db + try { + my $dbfile = FixMyStreet->path_to('../data/analytics.sqlite'); + my $db = DBI->connect("dbi:SQLite:dbname=$dbfile", undef, undef) or die "$DBI::errstr\n"; + my $sth = $db->prepare("INSERT INTO location_searches_with_no_results + (datetime, cobrand, geocoder, url, user_input) + VALUES (?, ?, ?, ?, ?)") or die $db->errstr . "\n"; + my $rv = $sth->execute( + POSIX::strftime("%Y-%m-%d %H:%M:%S", localtime(time())), + $c->cobrand->moniker, + $c->cobrand->get_geocoder(), + $c->stash->{geocoder_url}, + $pc, + ); + } catch { + $c->log->debug("Unable to log to analytics.sqlite: $_"); + }; + return; } diff --git a/perllib/FixMyStreet/App/Controller/Offline.pm b/perllib/FixMyStreet/App/Controller/Offline.pm index 57cbe201c..adb3de14d 100644 --- a/perllib/FixMyStreet/App/Controller/Offline.pm +++ b/perllib/FixMyStreet/App/Controller/Offline.pm @@ -33,42 +33,12 @@ sub manifest: Path("/.well-known/manifest.webmanifest") { my ($self, $c) = @_; $c->res->content_type('application/manifest+json'); - my $theme = $c->model('DB::ManifestTheme')->find({ cobrand => $c->cobrand->moniker }); - unless ( $theme ) { - $theme = $c->model('DB::ManifestTheme')->new({ - name => $c->stash->{site_name}, - short_name => $c->stash->{site_name}, - background_colour => '#ffffff', - theme_colour => '#ffd000', - }); - } - - my @icons; - my $uri = '/theme/' . $c->cobrand->moniker; - my $theme_path = path(FixMyStreet->path_to('web' . $uri)); - $theme_path->visit( - sub { - my ($x, $y, $typ) = Image::Size::imgsize($_->stringify); - push @icons, { - src => join('/', $uri, $_->basename), - sizes => join('x', $x, $y), - type => $typ eq 'PNG' ? 'image/png' : $typ eq 'GIF' ? 'image/gif' : $typ eq 'JPG' ? 'image/jpeg' : '', - }; - } - ); - - unless (@icons) { - push @icons, - { src => "/cobrands/fixmystreet/images/192.png", sizes => "192x192", type => "image/png" }, - { src => "/cobrands/fixmystreet/images/512.png", sizes => "512x512", type => "image/png" }; - } - my $data = { - name => $theme->name, - short_name => $theme->short_name, - background_color => $theme->background_colour, - theme_color => $theme->theme_colour, - icons => \@icons, + name => $c->stash->{manifest_theme}->{name}, + short_name => $c->stash->{manifest_theme}->{short_name}, + background_color => $c->stash->{manifest_theme}->{background_colour}, + theme_color => $c->stash->{manifest_theme}->{theme_colour}, + icons => $c->stash->{manifest_theme}->{icons}, lang => $c->stash->{lang_code}, display => "minimal-ui", start_url => "/?pwa", @@ -82,6 +52,72 @@ sub manifest: Path("/.well-known/manifest.webmanifest") { $c->res->body($json); } +sub _stash_manifest_theme : Private { + my ($self, $c, $cobrand) = @_; + + $c->stash->{manifest_theme} = $c->forward('_find_manifest_theme', [ $cobrand ]); +} + +sub _find_manifest_theme : Private { + my ($self, $c, $cobrand, $ignore_cache_and_defaults) = @_; + + my $key = "manifest_theme:$cobrand"; + # ignore_cache_and_defaults is only used in the admin, so no harm bypassing cache + my $manifest_theme = $ignore_cache_and_defaults ? undef : Memcached::get($key); + + unless ( $manifest_theme ) { + my $theme = $c->model('DB::ManifestTheme')->find({ cobrand => $cobrand }); + unless ( $theme ) { + $theme = $c->model('DB::ManifestTheme')->new({ + name => $c->stash->{site_name}, + short_name => $c->stash->{site_name}, + background_colour => '#ffffff', + theme_colour => '#ffd000', + }); + } + + my @icons; + my $uri = '/theme/' . $cobrand; + my $theme_path = path(FixMyStreet->path_to('web' . $uri)); + $theme_path->visit( + sub { + my ($x, $y, $typ) = Image::Size::imgsize($_->stringify); + push @icons, { + src => join('/', $uri, $_->basename), + sizes => join('x', $x, $y), + type => $typ eq 'PNG' ? 'image/png' : $typ eq 'GIF' ? 'image/gif' : $typ eq 'JPG' ? 'image/jpeg' : '', + }; + } + ); + + unless (@icons || $ignore_cache_and_defaults) { + push @icons, + { src => "/cobrands/fixmystreet/images/192.png", sizes => "192x192", type => "image/png" }, + { src => "/cobrands/fixmystreet/images/512.png", sizes => "512x512", type => "image/png" }; + } + + $manifest_theme = { + icons => \@icons, + background_colour => $theme->background_colour, + theme_colour => $theme->theme_colour, + name => $theme->name, + short_name => $theme->short_name, + }; + + unless ($ignore_cache_and_defaults) { + Memcached::set($key, $manifest_theme); + } + } + + return $manifest_theme; +} + +sub _clear_manifest_theme_cache : Private { + my ($self, $c, $cobrand ) = @_; + + Memcached::delete("manifest_theme:$cobrand"); +} + __PACKAGE__->meta->make_immutable; 1; diff --git a/perllib/FixMyStreet/App/Controller/Root.pm b/perllib/FixMyStreet/App/Controller/Root.pm index caaa260ff..71dcf8e27 100644 --- a/perllib/FixMyStreet/App/Controller/Root.pm +++ b/perllib/FixMyStreet/App/Controller/Root.pm @@ -42,6 +42,8 @@ sub auto : Private { $c->forward('check_password_expiry'); $c->detach('/auth/redirect') if $c->cobrand->call_hook('check_login_disallowed'); + $c->forward('/offline/_stash_manifest_theme', [ $c->cobrand->moniker ]); + return 1; } diff --git a/perllib/FixMyStreet/App/Form/ManifestTheme.pm b/perllib/FixMyStreet/App/Form/ManifestTheme.pm new file mode 100644 index 000000000..aa2d467d6 --- /dev/null +++ b/perllib/FixMyStreet/App/Form/ManifestTheme.pm @@ -0,0 +1,68 @@ +package FixMyStreet::App::Form::ManifestTheme; + +use Path::Tiny; +use File::Copy; +use Digest::SHA qw(sha1_hex); +use File::Basename; +use HTML::FormHandler::Moose; +use FixMyStreet::App::Form::I18N; +use List::MoreUtils qw(uniq); +extends 'HTML::FormHandler::Model::DBIC'; +use namespace::autoclean; + +has 'cobrand' => ( isa => 'Str', is => 'ro' ); + +has '+widget_name_space' => ( default => sub { ['FixMyStreet::App::Form::Widget'] } ); +has '+widget_tags' => ( default => sub { { wrapper_tag => 'p' } } ); +has '+item_class' => ( default => 'ManifestTheme' ); +has_field 'cobrand' => ( type => 'Select', empty_select => 'Select a cobrand', required => 1 ); +has_field 'name' => ( required => 1 ); +has_field 'short_name' => ( required => 1 ); +has_field 'background_colour' => ( required => 0 ); +has_field 'theme_colour' => ( required => 0 ); +has_field 'icon' => ( required => 0, type => 'Upload', label => "Add icon" ); +has_field 'delete_icon' => ( type => 'Multiple' ); + +sub _build_language_handle { FixMyStreet::App::Form::I18N->new } + +sub options_cobrand { + my @cobrands = uniq sort map { $_->{moniker} } FixMyStreet::Cobrand->available_cobrand_classes; + return map { $_ => $_ } @cobrands; +} + +sub validate { + my $self = shift; + + my $value = $self->value; + my $cobrand = $value->{cobrand} || $self->cobrand; + my $upload = $value->{icon}; + + if ( $upload ) { + if( $upload->type !~ /^image/ ) { + $self->field('icon')->add_error( _("File type not recognised. Please upload an image.") ); + return; + } + + my $uri = '/theme/' . $cobrand; + my $theme_path = path(FixMyStreet->path_to('web' . $uri)); + $theme_path->mkpath; + FixMyStreet::PhotoStorage::base64_decode_upload(undef, $upload); + my ($p, $n, $ext) = fileparse($upload->filename, qr/\.[^.]*/); + my $key = sha1_hex($upload->slurp) . $ext; + my $out = path($theme_path, $key); + unless (copy($upload->tempname, $out)) { + $self->field('icon')->add_error( _("Sorry, we couldn't save your file(s), please try again.") ); + return; + } + } + + foreach my $delete_icon ( @{ $value->{delete_icon} } ) { + unlink FixMyStreet->path_to('web', $delete_icon); + } + + return 1; +} + +__PACKAGE__->meta->make_immutable; + +1; diff --git a/perllib/FixMyStreet/Cobrand/Default.pm b/perllib/FixMyStreet/Cobrand/Default.pm index 9851b4896..695487268 100644 --- a/perllib/FixMyStreet/Cobrand/Default.pm +++ b/perllib/FixMyStreet/Cobrand/Default.pm @@ -685,6 +685,7 @@ sub admin_pages { $pages->{flagged} = [ _('Flagged'), 7 ]; $pages->{states} = [ _('States'), 8 ]; $pages->{config} = [ _('Configuration'), 9]; + $pages->{manifesttheme} = [ _('Manifest Theme'), 11]; $pages->{user_import} = [ undef, undef ]; }; # And some that need special permissions diff --git a/perllib/FixMyStreet/DB/Result/AdminLog.pm b/perllib/FixMyStreet/DB/Result/AdminLog.pm index 5564d829a..4c89138c9 100644 --- a/perllib/FixMyStreet/DB/Result/AdminLog.pm +++ b/perllib/FixMyStreet/DB/Result/AdminLog.pm @@ -91,6 +91,10 @@ sub link { my $category = $self->object; return "/admin/body/" . $category->body_id . '/' . $category->category; } + if ($type eq 'manifesttheme') { + my $theme = $self->object; + return "/admin/manifesttheme/" . $theme->cobrand; + } return ''; } @@ -114,6 +118,7 @@ sub object_summary { role => 'name', template => 'title', category => 'category', + manifesttheme => 'cobrand', }; my $thing = $type_to_thing->{$self->object_type} || 'id'; @@ -130,6 +135,7 @@ sub object { template => 'ResponseTemplate', category => 'Contact', update => 'Comment', + manifesttheme => 'ManifestTheme', }; $type = $type_to_object->{$type} || ucfirst $type; my $object = $self->result_source->schema->resultset($type)->find($id); diff --git a/perllib/FixMyStreet/DB/Result/Comment.pm b/perllib/FixMyStreet/DB/Result/Comment.pm index bac183271..b217bf96c 100644 --- a/perllib/FixMyStreet/DB/Result/Comment.pm +++ b/perllib/FixMyStreet/DB/Result/Comment.pm @@ -292,6 +292,7 @@ sub is_latest { { problem_id => $self->problem_id, state => 'confirmed' }, { order_by => [ { -desc => 'confirmed' }, { -desc => 'id' } ] } )->first; + return unless $latest_update; return $latest_update->id == $self->id; } diff --git a/perllib/FixMyStreet/Geocode/Bexley.pm b/perllib/FixMyStreet/Geocode/Bexley.pm index a70a42cd1..8a1a886bb 100644 --- a/perllib/FixMyStreet/Geocode/Bexley.pm +++ b/perllib/FixMyStreet/Geocode/Bexley.pm @@ -23,6 +23,8 @@ sub string { my $js = query_layer($s); return $osm unless $js && @{$js->{features}}; + $c->stash->{geocoder_url} = $s; + my ( $error, @valid_locations, $latitude, $longitude, $address ); foreach (sort { $a->{properties}{ADDRESS} cmp $b->{properties}{ADDRESS} } @{$js->{features}}) { my @lines = @{$_->{geometry}{coordinates}}; diff --git a/perllib/FixMyStreet/Geocode/Bing.pm b/perllib/FixMyStreet/Geocode/Bing.pm index ee5e15f8c..1d39d911f 100644 --- a/perllib/FixMyStreet/Geocode/Bing.pm +++ b/perllib/FixMyStreet/Geocode/Bing.pm @@ -38,6 +38,7 @@ sub string { $url .= '&userLocation=' . $params->{centre} if $params->{centre}; $url .= '&c=' . $params->{bing_culture} if $params->{bing_culture}; + $c->stash->{geocoder_url} = $url; my $js = FixMyStreet::Geocode::cache('bing', $url, 'key=' . FixMyStreet->config('BING_MAPS_API_KEY')); if (!$js) { return { error => _('Sorry, we could not parse that location. Please try again.') }; diff --git a/perllib/FixMyStreet/Geocode/Google.pm b/perllib/FixMyStreet/Geocode/Google.pm index 455d9cec0..ffbad96ba 100644 --- a/perllib/FixMyStreet/Geocode/Google.pm +++ b/perllib/FixMyStreet/Geocode/Google.pm @@ -49,6 +49,7 @@ sub string { $url .= '&components=' . $components if $components; + $c->stash->{geocoder_url} = $url; my $args = 'key=' . FixMyStreet->config('GOOGLE_MAPS_API_KEY'); my $js = FixMyStreet::Geocode::cache('google', $url, $args, qr/"status"\s*:\s*"(OVER_QUERY_LIMIT|REQUEST_DENIED|INVALID_REQUEST|UNKNOWN_ERROR)"/); if (!$js) { diff --git a/perllib/FixMyStreet/Geocode/OSM.pm b/perllib/FixMyStreet/Geocode/OSM.pm index b979e2a10..20e653cf6 100644 --- a/perllib/FixMyStreet/Geocode/OSM.pm +++ b/perllib/FixMyStreet/Geocode/OSM.pm @@ -47,6 +47,7 @@ sub string { if $params->{country}; $url .= join('&', map { "$_=$query_params{$_}" } sort keys %query_params); + $c->stash->{geocoder_url} = $url; my $js = FixMyStreet::Geocode::cache('osm', $url); if (!$js) { return { error => _('Sorry, we could not find that location.') }; diff --git a/perllib/FixMyStreet/Geocode/Zurich.pm b/perllib/FixMyStreet/Geocode/Zurich.pm index 0b85ab7b2..b0c0b528e 100644 --- a/perllib/FixMyStreet/Geocode/Zurich.pm +++ b/perllib/FixMyStreet/Geocode/Zurich.pm @@ -97,6 +97,7 @@ sub string { my $cache_dir = path(FixMyStreet->config('GEO_CACHE'), 'zurich')->absolute(FixMyStreet->path_to()); my $cache_file = $cache_dir->child(md5_hex($s)); my $result; + $c->stash->{geocoder_url} = $s; if (-s $cache_file && -M $cache_file <= 7 && !FixMyStreet->config('STAGING_SITE')) { $result = retrieve($cache_file); } else { diff --git a/perllib/FixMyStreet/PhotoStorage.pm b/perllib/FixMyStreet/PhotoStorage.pm index 9b0e5c9c3..256d46361 100644 --- a/perllib/FixMyStreet/PhotoStorage.pm +++ b/perllib/FixMyStreet/PhotoStorage.pm @@ -58,8 +58,10 @@ sub base64_decode_upload { print $fh $decoded; close $fh } else { - $c->log->info('Couldn\'t open temp file to save base64 decoded image: ' . $!); - $c->stash->{photo_error} = _("Sorry, we couldn't save your file(s), please try again."); + if ($c) { + $c->log->info('Couldn\'t open temp file to save base64 decoded image: ' . $!); + $c->stash->{photo_error} = _("Sorry, we couldn't save your file(s), please try again."); + } return (); } } diff --git a/perllib/Memcached.pm b/perllib/Memcached.pm index 05ceeb615..d03897e5a 100644 --- a/perllib/Memcached.pm +++ b/perllib/Memcached.pm @@ -29,4 +29,8 @@ sub set { instance->set(@_); } +sub delete { + instance->delete(@_); +} + 1; diff --git a/t/app/controller/admin/manifesttheme.t b/t/app/controller/admin/manifesttheme.t new file mode 100644 index 000000000..c1b2d4542 --- /dev/null +++ b/t/app/controller/admin/manifesttheme.t @@ -0,0 +1,340 @@ +use Path::Tiny; +use FixMyStreet::DB; +use FixMyStreet::TestMech; + +my $mech = FixMyStreet::TestMech->new; + +my $superuser = $mech->create_user_ok('superuser@example.com', name => 'Super User', is_superuser => 1); + +$mech->log_in_ok( $superuser->email ); + +FixMyStreet::override_config { + ALLOWED_COBRANDS => [ 'lincolnshire', 'tfl', 'fixmystreet' ], +}, sub { + +ok $mech->host('lincolnshire.fixmystreet.com'); + +subtest "theme link on cobrand admin goes to create form if no theme exists" => sub { + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 0, "no themes yet" ); + + $mech->get_ok("/admin"); + $mech->follow_link_ok({ text => "Manifest Theme" }); + + is $mech->res->previous->code, 302, "got 302 for redirect"; + is $mech->res->previous->base->path, "/admin/manifesttheme", "redirected from index"; + is $mech->uri->path, '/admin/manifesttheme/create', "redirected to create page"; +}; + +subtest "name and short_name are required fields" => sub { + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 0, "no themes yet" ); + + $mech->get_ok("/admin/manifesttheme/create"); + $mech->content_lacks("Delete theme"); + + $mech->submit_form_ok({}); + is $mech->uri->path, '/admin/manifesttheme/create', "stayed on create page"; + $mech->content_contains("field is required"); + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 0, "theme not created" ); + + $mech->get_ok("/admin/manifesttheme/create"); + $mech->submit_form_ok({ with_fields => { short_name => "Lincs FMS" } }); + is $mech->uri->path, '/admin/manifesttheme/create', "stayed on create page"; + $mech->content_contains("field is required", "name is required"); + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 0, "theme not created" ); + + $mech->get_ok("/admin/manifesttheme/create"); + $mech->submit_form_ok({ with_fields => { name => "Lincolnshire FixMyStreet" } }); + is $mech->uri->path, '/admin/manifesttheme/create', "stayed on create page"; + $mech->content_contains("field is required", "short_name is required"); + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 0, "theme not created" ); +}; + +subtest "cobrand admin lets you create a new theme" => sub { + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 0, "no themes yet" ); + + $mech->get_ok("/admin/manifesttheme/create"); + $mech->content_lacks("Delete theme"); + + my $fields = { + name => "Lincolnshire FixMyStreet", + short_name => "Lincs FMS", + }; + $mech->submit_form_ok( { with_fields => $fields } ); + is $mech->uri->path, '/admin/manifesttheme/lincolnshire', "redirected to edit page"; + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 1, "theme was created" ); + + my $theme = FixMyStreet::DB->resultset('ManifestTheme')->find({ cobrand => 'lincolnshire' }); + is $theme->name, "Lincolnshire FixMyStreet"; + is $theme->short_name, "Lincs FMS"; + is $theme->background_colour, undef; + + my $log = $superuser->admin_logs->search({}, { order_by => { -desc => 'id' } })->first; + is $log->object_id, $theme->id; + is $log->action, "add"; + is $log->object_summary, "lincolnshire"; + is $log->link, "/admin/manifesttheme/lincolnshire"; +}; + +subtest "cobrand admin lets you update an existing theme" => sub { + $mech->get_ok("/admin/manifesttheme/lincolnshire"); + + my $fields = { + background_colour => "#663399", + theme_colour => "rgb(102, 51, 153)", + }; + $mech->submit_form_ok( { with_fields => $fields } ); + + my $theme = FixMyStreet::DB->resultset('ManifestTheme')->find({ cobrand => 'lincolnshire' }); + is $theme->background_colour, "#663399"; + is $theme->theme_colour, "rgb(102, 51, 153)"; + + my $log = $superuser->admin_logs->search({}, { order_by => { -desc => 'id' } })->first; + is $log->object_id, $theme->id; + is $log->action, "edit"; +}; + +subtest "cobrand admin lets you add an icon to an existing theme" => sub { + $mech->get_ok("/admin/manifesttheme/lincolnshire"); + + my $sample_jpeg = path(__FILE__)->parent->parent->child("sample.jpg"); + ok $sample_jpeg->exists, "sample image $sample_jpeg exists"; + my $icon_filename = '74e3362283b6ef0c48686fb0e161da4043bbcc97.jpg'; + + $mech->post( '/admin/manifesttheme/lincolnshire', + Content_Type => 'form-data', + Content => { + name => "Lincolnshire FixMyStreet", + short_name => "Lincs FMS", + background_colour => "#663399", + theme_colour => "rgb(102, 51, 153)", + cobrand => 'lincolnshire', + icon => [ $sample_jpeg, undef, Content_Type => 'image/jpeg' ], + }, + ); + ok $mech->success, 'Posted request successfully'; + + is $mech->uri->path, '/admin/manifesttheme/lincolnshire', "redirected back to edit page"; + $mech->content_contains("<img src=\"/theme/lincolnshire/" . $icon_filename); + $mech->content_contains("<td class=\"icon-size\">133x100</td>"); + my $icon_dest = path(FixMyStreet->path_to('web/theme/lincolnshire/', $icon_filename)); + ok $icon_dest->exists, "Icon stored on disk"; +}; + +subtest "cobrand admin lets you delete an icon from an existing theme" => sub { + my $icon_filename = '74e3362283b6ef0c48686fb0e161da4043bbcc97.jpg'; + my $icon_dest = path(FixMyStreet->path_to('web/theme/lincolnshire/', $icon_filename)); + ok $icon_dest->exists, "Icon exists on disk"; + + $mech->get_ok("/admin/manifesttheme/lincolnshire"); + my $fields = { + delete_icon => "/theme/lincolnshire/$icon_filename", + }; + $mech->submit_form_ok( { with_fields => $fields } ); + + is $mech->uri->path, '/admin/manifesttheme/lincolnshire', "redirected back to edit page"; + $mech->content_lacks("<img src=\"/theme/lincolnshire/" . $icon_filename); + $mech->content_lacks("<td class=\"icon-size\">133x100</td>"); + ok !$icon_dest->exists, "Icon removed from disk"; +}; + +subtest "cobrand admin rejects non-images" => sub { + $mech->get_ok("/admin/manifesttheme/lincolnshire"); + + my $sample_pdf = path(__FILE__)->parent->parent->child("sample.pdf"); + ok $sample_pdf->exists, "sample image $sample_pdf exists"; + + $mech->post( '/admin/manifesttheme/lincolnshire', + Content_Type => 'form-data', + Content => { + name => "Lincolnshire FixMyStreet", + short_name => "Lincs FMS", + background_colour => "#663399", + theme_colour => "rgb(102, 51, 153)", + cobrand => 'lincolnshire', + icon => [ $sample_pdf, undef, Content_Type => 'application/pdf' ], + }, + ); + ok $mech->success, 'Posted request successfully'; + + is $mech->uri->path, '/admin/manifesttheme/lincolnshire', "redirected back to edit page"; + $mech->content_lacks("90f7a64043fb458d58de1a0703a6355e2856b15e.pdf"); + $mech->content_contains("File type not recognised. Please upload an image."); +}; + +subtest "theme link on cobrand admin goes to edit form when theme exists" => sub { + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 1, "theme exists" ); + + $mech->get_ok("/admin"); + $mech->follow_link_ok({ text => "Manifest Theme" }); + + is $mech->res->previous->code, 302, "got 302 for redirect"; + is $mech->res->previous->base->path, "/admin/manifesttheme", "redirected from index"; + is $mech->uri->path, '/admin/manifesttheme/lincolnshire', "redirected to edit page"; +}; + +subtest "create page on cobrand admin redirects to edit form when theme exists" => sub { + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 1, "theme exists" ); + + $mech->get_ok("/admin/manifesttheme/create"); + + is $mech->res->previous->code, 302, "got 302 for redirect"; + is $mech->uri->path, '/admin/manifesttheme/lincolnshire', "redirected to edit page"; +}; + +subtest "can delete theme" => sub { + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 1, "theme exists" ); + + my $theme_id = FixMyStreet::DB->resultset('ManifestTheme')->find({ cobrand => 'lincolnshire' })->id; + + # Add an icon so we can test it gets deleted when the theme is deleted + my $sample_jpeg = path(__FILE__)->parent->parent->child("sample.jpg"); + ok $sample_jpeg->exists, "sample image $sample_jpeg exists"; + my $icon_filename = '74e3362283b6ef0c48686fb0e161da4043bbcc97.jpg'; + + $mech->post( '/admin/manifesttheme/lincolnshire', + Content_Type => 'form-data', + Content => { + name => "Lincolnshire FixMyStreet", + short_name => "Lincs FMS", + background_colour => "#663399", + theme_colour => "rgb(102, 51, 153)", + cobrand => "lincolnshire", + icon => [ $sample_jpeg, undef, Content_Type => 'image/jpeg' ], + }, + ); + ok $mech->success, 'Posted request successfully'; + + is $mech->uri->path, '/admin/manifesttheme/lincolnshire', "redirected back to edit page"; + my $icon_dest = path(FixMyStreet->path_to('web/theme/lincolnshire/', $icon_filename)); + ok $icon_dest->exists, "Icon stored on disk"; + + $mech->submit_form_ok({ button => 'delete_theme' }); + is $mech->uri->path, '/admin/manifesttheme/create', "redirected to create page"; + + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 0, "theme deleted" ); + ok !$icon_dest->exists, "Icon removed from disk"; + + my $log = $superuser->admin_logs->search({}, { order_by => { -desc => 'id' } })->first; + is $log->object_id, $theme_id; + is $log->action, "delete"; +}; + +subtest "can't edit another cobrand's theme" => sub { + FixMyStreet::DB->resultset('ManifestTheme')->create({ + cobrand => "tfl", + name => "Transport for London Street Care", + short_name => "TfL Street Care", + }); + + $mech->get("/admin/manifesttheme/tfl"); + ok !$mech->res->is_success(), "want a bad response"; + is $mech->res->code, 404, "got 404"; +}; + +ok $mech->host('www.fixmystreet.com'); + +subtest "fms cobrand lets you view all manifest themes" => sub { + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 1, "theme already exists" ); + + $mech->get_ok("/admin"); + $mech->follow_link_ok({ text => "Manifest Theme" }); + + is $mech->uri->path, '/admin/manifesttheme', "taken to list page"; + + $mech->content_contains("Transport for London Street Care"); + $mech->content_contains("TfL Street Care"); + +}; + +subtest "fms cobrand lets you edit a cobrand's manifest theme" => sub { + $mech->get_ok("/admin/manifesttheme"); + $mech->follow_link_ok({ url => "manifesttheme/tfl" }) or diag $mech->content; + + my $fields = { + name => "Transport for London Report It", + }; + $mech->submit_form_ok( { with_fields => $fields } ); + is $mech->uri->path, '/admin/manifesttheme', "redirected back to list page"; + + my $theme = FixMyStreet::DB->resultset('ManifestTheme')->find({ cobrand => 'tfl' }); + is $theme->name, "Transport for London Report It"; + +}; + +subtest "fms cobrand lets you create a new manifest theme" => sub { + $mech->get_ok("/admin/manifesttheme"); + $mech->follow_link_ok({ text => "Create" }); + + my $fields = { + name => "FixMyStreet Pro", + short_name => "FMS Pro", + cobrand => "fixmystreet", + }; + $mech->submit_form_ok( { with_fields => $fields } ); + is $mech->uri->path, '/admin/manifesttheme', "redirected to list page"; + + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 2, "theme added" ); + my $theme = FixMyStreet::DB->resultset('ManifestTheme')->find({ cobrand => 'fixmystreet' }); + is $theme->name, "FixMyStreet Pro"; +}; + +subtest "fms cobrand prevents you creating a duplicate theme" => sub { + $mech->get_ok("/admin/manifesttheme"); + $mech->follow_link_ok({ text => "Create" }); + + my $fields = { + name => "FixMyStreet Pro", + short_name => "FMS Pro", + cobrand => "fixmystreet", + }; + $mech->submit_form_ok( { with_fields => $fields } ); + is $mech->uri->path, '/admin/manifesttheme/create', "stayed on create form"; + + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 2, "theme not added" ); +}; + +subtest "fms cobrand prevents creating a duplicate by editing" => sub { + $mech->get_ok("/admin/manifesttheme"); + $mech->follow_link_ok({ url => "manifesttheme/tfl" }); + + my $fields = { + cobrand => "fixmystreet", + }; + $mech->submit_form_ok( { with_fields => $fields } ); + is $mech->uri->path, '/admin/manifesttheme/tfl', "stayed on edit page"; +}; + +}; + +FixMyStreet::override_config { + ALLOWED_COBRANDS => [ 'fixamingata' ], +}, sub { + +ok $mech->host("www.fixamingata.se"), "change host to FixaMinGata"; + +subtest "single cobrand behaves correctly" => sub { + FixMyStreet::DB->resultset('ManifestTheme')->delete_all; + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 0, "themes all deleted" ); + + $mech->get_ok("/admin/manifesttheme"); + is $mech->uri->path, '/admin/manifesttheme/create', "redirected to create page"; + + my $fields = { + name => "FixaMinGata Theme Test", + short_name => "FixaMinGata Short Name", + cobrand => "fixamingata", + }; + $mech->submit_form_ok( { with_fields => $fields } ); + is $mech->uri->path, '/admin/manifesttheme/fixamingata', "redirected to edit form page"; + $mech->content_contains("FixaMinGata Theme Test"); + $mech->content_contains("FixaMinGata Short Name"); + + is( FixMyStreet::DB->resultset('ManifestTheme')->count, 1, "theme added" ); + my $theme = FixMyStreet::DB->resultset('ManifestTheme')->find({ cobrand => 'fixamingata' }); + is $theme->name, "FixaMinGata Theme Test"; +}; + + +}; + +done_testing(); diff --git a/t/app/controller/admin/users.t b/t/app/controller/admin/users.t index 2e3ad9e5a..a36a4187a 100644 --- a/t/app/controller/admin/users.t +++ b/t/app/controller/admin/users.t @@ -564,7 +564,10 @@ subtest "Send login email from admin for unverified email" => sub { }; subtest "Anonymizing user from admin" => sub { - $mech->create_problems_for_body(4, 2237, 'Title'); + my ($problem) = $mech->create_problems_for_body(4, 2237, 'Title'); + $mech->create_comment_for_problem($problem, $user, $user->name, 'An update', 'f', 'confirmed', 'confirmed'); + $mech->create_comment_for_problem($problem, $user, $user->name, '2nd update', 't', 'confirmed', 'fixed - user'); + $mech->create_comment_for_problem($problem, $user, $user->name, '3rd update', 'f', 'unconfirmed', 'confirmed'); my $count_p = FixMyStreet::DB->resultset('Problem')->search({ user_id => $user->id })->count; my $count_u = FixMyStreet::DB->resultset('Comment')->search({ user_id => $user->id })->count; $mech->get_ok( '/admin/users/' . $user->id ); @@ -586,6 +589,12 @@ subtest "Hiding user's reports from admin" => sub { is $c, $count_u; }; +subtest "Hiding user with only unconfirmed updates does not error" => sub { + FixMyStreet::DB->resultset('Comment')->search({ user_id => $user->id, state => 'hidden' })->update({ state => 'unconfirmed' }); + $mech->get_ok( '/admin/users/' . $user->id ); + $mech->submit_form_ok({ button => 'hide_everywhere' }); +}; + subtest "Logging user out" => sub { my $mech2 = FixMyStreet::TestMech->new; $mech2->log_in_ok($user->email); diff --git a/t/app/controller/around.t b/t/app/controller/around.t index bd2bf2cee..cd992270f 100644 --- a/t/app/controller/around.t +++ b/t/app/controller/around.t @@ -261,6 +261,9 @@ subtest 'check category, status and extra filtering works on /around' => sub { $mech->get_ok( '/around?filter_category=Pothole&bbox=' . $bbox ); $mech->content_contains('<option value="Pothole" selected>'); $mech->content_contains('<optgroup label="Environment">'); + + $mech->get_ok( '/around?filter_group=Environment&bbox=' . $bbox ); + $mech->content_contains('<option value="Flytipping" selected>'); }; $json = $mech->get_ok_json( '/around?ajax=1&filter_category=Pothole&bbox=' . $bbox ); diff --git a/t/app/controller/offline.t b/t/app/controller/offline.t index d2a5009ec..876475264 100644 --- a/t/app/controller/offline.t +++ b/t/app/controller/offline.t @@ -1,5 +1,7 @@ use FixMyStreet::TestMech; +use FixMyStreet::DB; use Path::Tiny; +use Memcached; my $mech = FixMyStreet::TestMech->new; @@ -11,6 +13,7 @@ FixMyStreet::override_config { my $image_path = path('t/app/controller/sample.jpg'); $image_path->copy($theme_dir->child('sample.jpg')); subtest 'manifest' => sub { + Memcached::delete("manifest_theme:test"); my $j = $mech->get_ok_json('/.well-known/manifest.webmanifest'); is $j->{name}, 'FixMyStreet', 'correct name'; is $j->{theme_color}, '#ffd000', 'correct theme colour'; @@ -20,6 +23,22 @@ FixMyStreet::override_config { sizes => '133x100' }, 'correct icon'; }; + subtest 'themed manifest' => sub { + Memcached::delete("manifest_theme:test"); + FixMyStreet::DB->resultset('ManifestTheme')->create({ + cobrand => "test", + name => "My Test Cobrand FMS", + short_name => "Test FMS", + background_colour => "#ff00ff", + theme_colour => "#ffffff", + }); + + my $j = $mech->get_ok_json('/.well-known/manifest.webmanifest'); + is $j->{name}, 'My Test Cobrand FMS', 'correctly overridden name'; + is $j->{short_name}, 'Test FMS', 'correctly overridden short_name'; + is $j->{background_color}, '#ff00ff', 'correctly overridden background colour'; + is $j->{theme_color}, '#ffffff', 'correctly overridden theme colour'; + }; $theme_dir->remove_tree; }; diff --git a/templates/web/base/admin/manifesttheme/form.html b/templates/web/base/admin/manifesttheme/form.html new file mode 100644 index 000000000..6d02487a6 --- /dev/null +++ b/templates/web/base/admin/manifesttheme/form.html @@ -0,0 +1,71 @@ +[% INCLUDE 'admin/header.html' title=loc('Theme') -%] + +<form method="post" enctype="multipart/form-data"> + <div class="admin-hint"> + <p>[% loc("The <strong>name</strong> is a string that represents the name of the web application as it is usually displayed to the user (e.g., amongst a list of other applications, or as a label for an icon).") %]</p> + </div> + [% form.field('name').render | safe %] + + <div class="admin-hint"> + <p>[% loc("The <strong>short name</strong> is a string that represents the name of the web application displayed to the user if there is not enough space to display name (e.g., as a label for an icon on the phone home screen).") %]</p> + </div> + [% form.field('short_name').render | safe %] + + <div class="admin-hint"> + <p>[% loc("The <strong>theme colour</strong> defines the default theme colour for the application. This sometimes affects how the OS displays the site (e.g., on Android's task switcher, the theme colour surrounds the site). Colours should be specified with CSS syntax, e.g. <strong><code>#ff00ff</code></strong> or <strong><code>rgb(255, 0, 255)</code></strong> or a named colour like <strong><code>fuchsia</code></strong>.") %]</p> + </div> + [% form.field('theme_colour').render | safe %] + + <div class="admin-hint"> + <p>[% loc("The <strong>background colour</strong> defines a placeholder background colour for the application splash screen before it has loaded. Colours should be specified with CSS syntax, e.g. <strong><code>#ff00ff</code></strong> or <strong><code>rgb(255, 0, 255)</code></strong> or a named colour like <strong><code>fuchsia</code></strong>.") %]</p> + </div> + [% form.field('background_colour').render | safe %] + + [% IF show_all %] + [% form.field('cobrand').render | safe %] + [% ELSE %] + <input type=hidden name=cobrand value='[% c.cobrand.moniker %]' /> + [% END %] + + <table> + <thead> + <tr> + <th>Icon</th> + <th>Size</th> + <th>Delete?</th> + </tr> + </thead> + <tbody> + [% FOREACH icon IN editing_manifest_theme.icons %] + <tr> + <td><img src="[% icon.src %]" /></td> + <td class="icon-size">[% icon.sizes %]</td> + <td><input type=checkbox name=delete_icon value='[% icon.src %]' /></td> + </tr> + [% END %] + <tr> + <td colspan=3> + <div class="admin-hint"> + <p>[% loc("The <strong>icons</strong> are used when the application is installed to the user's home screen. Icons must be <strong>square</strong>, with <strong>512x512</strong>px and <strong>192x192</strong>px being the most common sizes.") %]</p> + </div> + [% form.field('icon').render | safe %] + </td> + </tr> + </tbody> + </table> + + <p> + <input class="btn" type="submit" name="submit" value="[% loc('Save changes') %]"> + </p> + [% IF form.item.id %] + <p> + <input class="btn-danger" type="submit" name="delete_theme" value="[% loc('Delete theme') %]" data-confirm="[% loc('Are you sure?') %]"> + </p> + [% END %] +</form> + +[% IF show_all %] + <p><a href="[% c.uri_for(c.controller.action_for('list')) %]">Return to themes list</a></p> +[% END %] + +[% INCLUDE 'admin/footer.html' %] diff --git a/templates/web/base/admin/manifesttheme/index.html b/templates/web/base/admin/manifesttheme/index.html new file mode 100644 index 000000000..df94c394f --- /dev/null +++ b/templates/web/base/admin/manifesttheme/index.html @@ -0,0 +1,35 @@ +[% INCLUDE 'admin/header.html' title=loc('Themes') %] + +<table> + <thead> + <tr> + <th> [% loc('Cobrand') %] </th> + <th> [% loc('Name') %] </th> + <th> [% loc('Short Name') %] </th> + <th> [% loc('Background Colour') %] </th> + <th> [% loc('Theme Colour') %] </th> + <th> </th> + </tr> + </thead> + <tbody> + [% FOR theme IN rs.all %] + <tr> + <td>[% theme.cobrand %]</td> + <td>[% theme.name %]</td> + <td>[% theme.short_name %]</td> + <td>[% theme.background_colour %]</td> + <td>[% theme.theme_colour %]</td> + <td> <a href="[% c.uri_for(c.controller.action_for('edit'), [theme.cobrand]) %]" class="btn">[% loc('Edit') %]</a> </td> + </tr> + [% END %] + </tbody> +</table> + + + +<p> + <a href="[% c.uri_for(c.controller.action_for('create')) %]">[% loc('Create') %]</a> + </p> + + +[% INCLUDE 'admin/footer.html' %] diff --git a/templates/web/base/common_header_tags.html b/templates/web/base/common_header_tags.html index 279f561df..6edcc63a4 100644 --- a/templates/web/base/common_header_tags.html +++ b/templates/web/base/common_header_tags.html @@ -2,6 +2,12 @@ <meta http-equiv="content-type" content="text/html; charset=utf-8"> <link rel="manifest" href="/.well-known/manifest.webmanifest"> +[% IF manifest_theme %] + <meta name='theme-color' content='[% manifest_theme.theme_colour %]'> + [% FOREACH icon IN manifest_theme.icons %] + <link rel="apple-touch-icon" sizes="[% icon.sizes %]" href="[% icon.src %]"> + [% END %] +[% END %] [% IF csrf_token %] <meta content="[% csrf_token %]" name="csrf-token" /> diff --git a/templates/web/base/reports/_list-filters.html b/templates/web/base/reports/_list-filters.html index 3125f63b1..f5d1faa65 100644 --- a/templates/web/base/reports/_list-filters.html +++ b/templates/web/base/reports/_list-filters.html @@ -2,7 +2,7 @@ [% BLOCK category_options %] [% FOR cat IN categories %] - <option value="[% cat.category %]"[% ' selected' IF filter_category.${cat.category} OR ( filter_group AND ( cat.get_extra_metadata('group') == filter_group OR cat.category == filter_group ) ) %]> + <option value="[% cat.category %]"[% ' selected' IF filter_category.${cat.category} OR ( filter_group AND ( cat.get_extra_metadata('group').grep(filter_group).size OR cat.category == filter_group ) ) %]> [% cat.category_display %] [%~ IF cat.get_extra_metadata('help_text') %] ([% cat.get_extra_metadata('help_text') %])[% END ~%] </option> diff --git a/templates/web/buckinghamshire/report/form/user_name.html b/templates/web/buckinghamshire/report/form/user_name.html new file mode 100644 index 000000000..0cefb1b1d --- /dev/null +++ b/templates/web/buckinghamshire/report/form/user_name.html @@ -0,0 +1,13 @@ +<!-- user_name.html --> +<label for="form_name">Full name +[% TRY %] + [% INCLUDE 'report/form/after_name.html' %] + [% CATCH file %] +[% END %] +</label> +[% IF field_errors.name %] + <p class='form-error'>[% field_errors.name %]</p> +[% END %] +<input type="text" class="form-control [% valid_class OR 'validName' %] js-form-name [% extra_class %]" + value="[% object.name || c.user.name | html %]" name="name" id="form_name"> +<!-- /user_name.html --> diff --git a/templates/web/fixmystreet.com/header_extra.html b/templates/web/fixmystreet.com/header_extra.html index 5292d4804..12d9fe9d4 100644 --- a/templates/web/fixmystreet.com/header_extra.html +++ b/templates/web/fixmystreet.com/header_extra.html @@ -10,7 +10,6 @@ <link rel="canonical" href="https://www.fixmystreet.com[% c.req.uri.path_query %]"> [% END %] -<meta name='theme-color' content='#ffd000'> <link rel="Shortcut Icon" type="image/x-icon" href="/cobrands/fixmystreet.com/favicon.ico"> [% INCLUDE 'tracking_code.html' %] diff --git a/web/cobrands/fixmystreet/admin.js b/web/cobrands/fixmystreet/admin.js index 8210f002f..52f4dd292 100644 --- a/web/cobrands/fixmystreet/admin.js +++ b/web/cobrands/fixmystreet/admin.js @@ -190,5 +190,12 @@ $(function(){ $('.js-metadata-items').on('click', '.js-metadata-option-remove', function(){ $(this).parents('.js-metadata-option').remove(); }); + + // On the manifest theme editing page we have tickboxes for deleting individual + // icons - ticking one of these should grey out that row to indicate it will be + // deleted upon form submission. + $("input[name=delete_icon]").change(function() { + $(this).closest("tr").toggleClass("is-deleted", this.checked); + }); }); diff --git a/web/cobrands/sass/_admin.scss b/web/cobrands/sass/_admin.scss index b1f914ca8..4ae019776 100644 --- a/web/cobrands/sass/_admin.scss +++ b/web/cobrands/sass/_admin.scss @@ -61,7 +61,10 @@ $button_bg_col: #a1a1a1; // also search bar (tables) } tr.is-deleted { background-color: #ffdddd; - td.contact-category { + img { + filter: grayscale(1); + } + td.contact-category, td.icon-size { text-decoration: line-through; } } |