Improve status codes and support HTML match responses

Change-Id: Ie11b68eb0836bb537a2869b87e78f3a695203e11
diff --git a/Changes b/Changes
index 9bd15fb..f7559e1 100755
--- a/Changes
+++ b/Changes
@@ -1,4 +1,4 @@
-0.38 2020-03-30
+0.38 2020-03-31
         - Support X-Forwarded-Host name for proxy.
         - Document API URI.
         - Improve redirect handling in proxy.
@@ -6,6 +6,9 @@
         - Added support for OAuth2 client listing.
         - Added requestMsg() methods to clients for retrieving
           data from the embedding server.
+        - Improve error status codes.
+        - Support HTML responses for match information.
+        - Reuse failure template.
 
 0.37 2020-01-16
         - Removed deprecated 'kalamar_test_port' helper.
diff --git a/Makefile.PL b/Makefile.PL
index 19284c1..ca7f350 100644
--- a/Makefile.PL
+++ b/Makefile.PL
@@ -18,7 +18,7 @@
     'Mojolicious::Plugin::TagHelpers::Pagination' => 0.07,
     'Mojolicious::Plugin::TagHelpers::MailToChiffre' => 0.10,
     'Mojolicious::Plugin::ClosedRedirect' => 0.14,
-    'Mojolicious::Plugin::Notifications' => 1.03,
+    'Mojolicious::Plugin::Notifications' => 1.05,
     'Mojolicious::Plugin::MailException' => 0.20,
     'Mojolicious::Plugin::Util::RandomString' => 0.08,
     'Mojolicious::Plugin::CHI' => 0.20,
diff --git a/dev/demo/match.html b/dev/demo/match.html
index 96b8781..a70cd0f 100644
--- a/dev/demo/match.html
+++ b/dev/demo/match.html
@@ -39,7 +39,7 @@
           </div>
 	        <p class="ref"><strong>Wertparameter</strong> by Hubi,Zwobot,4; published on 2005-03-28 as WWW.03313 (WPD)</p>
 	      </li>
-        <li data-match-id="p15845-15846"
+        <li class="active" data-match-id="p15845-15846"
             data-text-sigle="GOE/AGI/00000"
             data-available-info="base/s=spans corenlp/c=spans corenlp/p=tokens corenlp/s=spans dereko/s=spans malt/d=rels opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans" data-info="{&quot;UID&quot;:0,&quot;author&quot;:&quot;Goethe, Johann Wolfgang von&quot;,&quot;corpusID&quot;:null,&quot;corpusSigle&quot;:&quot;GOE&quot;,&quot;docID&quot;:null,&quot;docSigle&quot;:&quot;GOE\/AGI&quot;,&quot;layerInfos&quot;:&quot;base\/s=spans corenlp\/c=spans corenlp\/p=tokens corenlp\/s=spans dereko\/s=spans malt\/d=rels opennlp\/p=tokens opennlp\/s=spans tt\/l=tokens tt\/p=tokens tt\/s=spans&quot;,&quot;matchID&quot;:&quot;p15845-15846&quot;,&quot;pubDate&quot;:&quot;1982&quot;,&quot;pubPlace&quot;:&quot;München&quot;,&quot;subTitle&quot;:&quot;Auch ich in Arkadien!&quot;,&quot;textID&quot;:null,&quot;textSigle&quot;:&quot;GOE\/AGI\/00000&quot;,&quot;title&quot;:&quot;Italienische Reise&quot;,&quot;desc&quot;:&quot;Ein wundervolles, wenn auch etwas langweiliges Buch, dass einen Roadtrip des berühmten deutschen Autors beschreibt.&quot;}"
             id="GOE/AGI/00000#p15845-15846">
diff --git a/dev/js/spec/matchSpec.js b/dev/js/spec/matchSpec.js
index 08c0ced..79133ee 100644
--- a/dev/js/spec/matchSpec.js
+++ b/dev/js/spec/matchSpec.js
@@ -395,6 +395,31 @@
       expect(m.matchID).toEqual("p85183-85184");
     });
 
+    it('should be initializable when active', function () {
+      var e = matchElementFactory();
+      e.setAttribute('class', 'active');
+
+      expect(e.classList.contains('active')).toBe(true);
+      expect(e["_match"]).toBe(undefined);
+
+      var m = matchClass.create(e);
+
+      expect(e["_match"]).not.toBe(undefined);
+      
+      // Open the match
+      m.init();
+      
+      expect(e["_match"]).not.toBe(undefined);
+
+      actions = e.querySelector("p.ref > div.action.button-group").children;
+      
+      expect(actions[0].getAttribute("class")).toEqual("metatable");
+      expect(actions[1].getAttribute("class")).toEqual("info");
+      expect(actions[2].getAttribute("class")).toEqual("tree");
+      
+      // Close the match
+      expect(e.querySelector("div.action.button-group > span.minimize")).toBe(null);
+    });
     
     it('should react to gui actions', function () {
       var e = matchElementFactory();
@@ -418,13 +443,15 @@
       expect(actions[0].getAttribute("class")).toEqual("metatable");
       expect(actions[1].getAttribute("class")).toEqual("info");
       expect(actions[2].getAttribute("class")).toEqual("tree");
+
+      expect(e.querySelector("div.action.button-group > span.minimize")).not.toBe(null);
       
       // Close the match
       m.minimize();
       expect(e.classList.contains('active')).toBe(false);
       expect(e["_match"]).not.toBe(undefined);
     });
-
+   
     it('should open tree menu', function () {      
       var e = matchElementFactory();
       var m = matchClass.create(e);
diff --git a/dev/js/src/init.js b/dev/js/src/init.js
index 478f87e..62d0800 100644
--- a/dev/js/src/init.js
+++ b/dev/js/src/init.js
@@ -141,39 +141,53 @@
     /**
      * Add actions to match entries
      */
-    var inactiveLi = d.querySelectorAll(
-      '#search > ol > li:not(.active)'
+    var li = d.querySelectorAll(
+      '#search > ol > li'
     );
     var matchCount = 0;
 
-    for (matchCount = 0; matchCount < inactiveLi.length; matchCount++) {
-      inactiveLi[matchCount].addEventListener('click', function (e) {
-        if (this._match !== undefined)
-          this._match.open();
-        else {
+    for (matchCount = 0; matchCount < li.length; matchCount++) {
+
+      let e = li[matchCount];
+
+      // Define class for active elements
+      if (e.classList.contains('active')) {
+        if (this._match === undefined) {
           // lazyLoad
-          matchClass.create(this).open();
+          matchClass.create(e).init();
         };
-        // This would prevent the sidebar to go back
-        // e.halt();
-      });
-      inactiveLi[matchCount].addEventListener('keydown', function (e) {
-        var code = _codeFromEvent(e);
-        
-        switch (code) {
-        case 32:
+      }
+
+      // Define class for inactive elements
+      else {
+        e.addEventListener('click', function (e) {
           if (this._match !== undefined)
-            this._match.toggle();
+            this._match.open();
           else {
             // lazyLoad
             matchClass.create(this).open();
           };
-          e.halt();
-          break;
-        };
-      });
+          // This would prevent the sidebar to go back
+          // e.halt();
+        });
+        e.addEventListener('keydown', function (e) {
+          var code = _codeFromEvent(e);
+          
+          switch (code) {
+          case 32:
+            if (this._match !== undefined)
+              this._match.toggle();
+            else {
+              // lazyLoad
+              matchClass.create(this).open();
+            };
+            e.halt();
+            break;
+          };
+        });
+      };
     };
-
+    
     // Add focus listener to aside
     var aside = d.getElementsByTagName('aside')[0];
 
diff --git a/dev/js/src/match.js b/dev/js/src/match.js
index 3c44656..f38a68a 100644
--- a/dev/js/src/match.js
+++ b/dev/js/src/match.js
@@ -137,6 +137,44 @@
       return this._avail.rels;
     },
 
+    /**
+     * Initialize match
+     */
+    init : function () {
+      if (this._initialized)
+        return this;
+
+      // Add actions unless it's already activated
+      var element = this._element;
+
+      // There is an element to open
+      if (element === undefined || element === null)
+        return undefined;
+      
+      // Add meta button
+      var refLine = element.querySelector("p.ref");
+
+      // No reference found
+      if (!refLine)
+        return undefined;
+
+      // Create panel
+      this.panel = matchPanelClass.create(this);
+
+      this._element.insertBefore(
+        this.panel.element(),
+        this._element.querySelector("p.ref")
+      );
+
+      // Insert before reference line
+      refLine.insertBefore(
+        this.panel.actions.element(),
+        refLine.firstChild
+      );
+
+      this._initialized = true;
+      return this;
+    },
 
     /**
      * Open match
@@ -157,14 +195,6 @@
       // Add active class to element
       element.classList.add('active');
 
-      // Already there
-      /*
-        if (element.classList.contains('action'))
-        return true;
-      */
-      if (this._initialized)
-        return true;
-      
       var btn = buttonGroupClass.create(
         ['action','button-view']
       );
@@ -174,29 +204,10 @@
         that.minimize();
       });
       element.appendChild(btn.element());
-
-      // Add meta button
-      var refLine = element.querySelector("p.ref");
-
-      // No reference found
-      if (!refLine)
-        return;
-
-      // Create panel
-      this.panel = matchPanelClass.create(this);
-
-      this._element.insertBefore(
-        this.panel.element(),
-        this._element.querySelector("p.ref")
-      );
-
-      // Insert before reference line
-      refLine.insertBefore(
-        this.panel.actions.element(),
-        refLine.firstChild
-      );
-
-      this._initialized = true;
+      
+      if (this.init() == undefined) {
+        return false;
+      };
       
       return true;
     },
diff --git a/kalamar.dict b/kalamar.dict
index 6c30001..b3715e6 100644
--- a/kalamar.dict
+++ b/kalamar.dict
@@ -47,7 +47,7 @@
     matchCount => 'Treffer',
     noMatches => 'Es wurden keine Treffer für <%== loc("searchjob") %> gefunden.',
     notFound => '404 - Seite nicht gefunden',
-    notIssued => 'Die Suche konnte nicht durchgeführt werden.',
+    notIssued => 'Die Aktion konnte nicht durchgeführt werden.',
     backendNotAvailable => 'Das Backend ist nicht verfügbar unter <code><%= app->korap->api =></code>!',
     jsFile => 'kalamar-<%= $Kalamar::VERSION %>-de.js',
     underConstruction => 'In Vorbereitung!',
@@ -127,7 +127,7 @@
     matchCount => '<%= quant($found, "match", "matches") %>',
     noMatches => 'There were no matches found for <%== loc("searchjob") %>.',
     notFound => '404 - Page not found',
-    notIssued => 'Unable to perform the search.',
+    notIssued => 'Unable to perform the action.',
     backendNotAvailable => 'The backend is not available at <code><%= app->korap->api %></code>!',
     glimpse => {
       -short => 'Glimpse',
diff --git a/lib/Kalamar.pm b/lib/Kalamar.pm
index 2f1632b..e5553a0 100644
--- a/lib/Kalamar.pm
+++ b/lib/Kalamar.pm
@@ -144,7 +144,7 @@
   $self->plugin(Notifications => {
     'Kalamar::Plugin::Notifications' => 1,
     JSON => 1,
-    'HTML' => 1
+    HTML => 1
   });
 
   # Localization framework
diff --git a/lib/Kalamar/Controller/Search.pm b/lib/Kalamar/Controller/Search.pm
index 9821b5c..7705d56 100644
--- a/lib/Kalamar/Controller/Search.pm
+++ b/lib/Kalamar/Controller/Search.pm
@@ -65,6 +65,11 @@
 
   $c->stash(q  => $query{q});
   $c->stash(ql => $query{ql});
+  $c->stash(title => $c->loc(
+    'searchtitle',
+    q => $query{'q'},
+    ql => $query{'ql'}
+  ));
 
   # Check validation
   if ($v->has_error) {
@@ -255,7 +260,6 @@
 
       # Only raised in case of connection errors
       if ($err_msg) {
-        # $c->stash('err_msg' => 'backendNotAvailable');
         $c->notify(error => { src => 'Backend' } => $err_msg)
       };
 
@@ -263,6 +267,7 @@
 
       # $c->_notify_on_errors(shift);
       return $c->render(
+        status => 400,
         template => 'failure'
       );
     }
@@ -408,9 +413,21 @@
     foreach my $failed_field (@{$v->failed}) {
       $c->notify(error => 'Parameter ' . quote($failed_field) . ' invalid');
     };
-    return $c->render(
-      status => 400,
-      json => $c->notifications('json')
+
+    return $c->respond_to(
+      html => sub {
+        shift->render(
+          status => 400,
+          template => 'failure'
+        );
+      },
+      any => sub {
+        my $c = shift;
+        $c->render(
+          status => 400,
+          json => $c->notifications('json')
+        );
+      }
     );
   };
 
@@ -451,21 +468,54 @@
       $json = _map_match($json);
       $c->stash(results => $json);
 
-      return $c->render(
-        json => $c->notifications(json => $json),
-        status => 200
+      return $c->respond_to(
+        html => sub {
+          my $c = shift;
+          return $c->render(
+            status => 200,
+            template => 'match_info'
+          );
+        },
+        any => sub {
+          my $c = shift;
+          return $c->render(
+            json => $c->notifications(json => $json),
+            status => 200
+          );
+        }
       );
-
-      return $json;
     }
   )
 
   # Deal with errors
   ->catch(
     sub {
-      return $c->render(
-        json => $c->notifications('json')
-      )
+      my $err_msg = shift;
+
+      # Only raised in case of connection errors
+      if ($err_msg) {
+        $c->notify(error => { src => 'Backend' } => $err_msg)
+      };
+
+      unless ($c->stash('status')) {
+        $c->stash(status => 400);
+      };
+
+      $c->app->log->debug("Receiving cached promised failure");
+
+      return $c->respond_to(
+        html => sub {
+          shift->render(
+            template => 'failure'
+          );
+        },
+        any => sub {
+          my $c = shift;
+          $c->render(
+            json => $c->notifications('json')
+          );
+        }
+      );
     }
   )
 
diff --git a/t/match_info.t b/t/match_info.t
index 486fe11..235ae66 100644
--- a/t/match_info.t
+++ b/t/match_info.t
@@ -25,14 +25,14 @@
 $fake_backend->pattern->defaults->{app}->log($t->app->log);
 
 # Query passed
-$t->get_ok('/corpus/WPD15/232/39681/p2133-2134?spans=false&foundry=*')
+$t->get_ok('/corpus/WPD15/232/39681/p2133-2134?spans=false&foundry=*&format=json')
   ->status_is(200)
   ->json_is('/textSigle', 'WPD15/232/39681')
   ->json_like('/snippet', qr!<span class=\"context-left\">!)
   ->header_isnt('X-Kalamar-Cache', 'true')
   ;
 
-$t->get_ok('/corpus/GOE/AGF/02286/p75682-75683')
+$t->get_ok('/corpus/GOE/AGF/02286/p75682-75683?format=json')
   ->status_is(200)
   ->json_is('/textSigle', 'GOE/AGF/02286')
   ->json_is('/title','Materialien zur Geschichte der Farbenlehre')
@@ -40,43 +40,39 @@
 
 # TODO:
 #   It's surprising, that it doesn't return a 404!
-$t->get_ok('/corpus/notfound/X/X/p0-1')
+$t->get_ok('/corpus/notfound/X/X/p0-1?format=json')
   ->status_is(200)
   ->json_is('/textSigle', 'NOTFOUND/X/X')
   ->json_is('/corpusID', undef)
   ;
 
-# TODO:
-#   Should probably return a 500!
-$t->get_ok('/corpus/fail/x/x/p0-0')
-  ->status_is(200)
+$t->get_ok('/corpus/fail/x/x/p0-0?format=json')
+  ->status_is(400)
   ->json_is('/notifications/0/0', 'error')
   ->json_like('/notifications/0/1', qr!Unable to load query response from .+?response_matchinfo_fail_x_x_p0-0\.json!)
   ;
 
 # TODO:
 #   Should probably return a 4xx!
-$t->get_ok('/corpus/GOE/AGF/02286/p-2-0')
-  ->status_is(200)
+$t->get_ok('/corpus/GOE/AGF/02286/p-2-0?format=json')
+  ->status_is(400)
   ->json_is('/notifications/0/0', 'error')
   ->json_is('/notifications/0/1', '730: Invalid match identifier')
   ;
 
-# TODO:
-#   It's surprising, that it doesn't return a 404!
-$t->get_ok('/corpus/notfound2/X/X/p0-1')
+$t->get_ok('/corpus/notfound2/X/X/p0-1?format=json')
   ->status_is(404)
   ->json_is('/notifications/0/0', 'error')
   ->json_is('/notifications/0/1', '404: Not Found')
   ;
 
-$t->get_ok('/corpus/brokenerr/X/X/p0-1')
+$t->get_ok('/corpus/brokenerr/X/X/p0-1?format=json')
   ->status_is(409)
   ->json_is('/notifications/0/0', 'error')
   ->json_is('/notifications/0/1', 'Message structure failed')
   ;
 
-$t->get_ok('/corpus/brokenwarn/X/X/p0-1')
+$t->get_ok('/corpus/brokenwarn/X/X/p0-1?format=json')
   ->status_is(200)
   ->json_is('/notifications/0/0', 'warn')
   ->json_is('/notifications/0/1', '1: Warning 1')
@@ -84,14 +80,14 @@
   ->json_is('/notifications/1/1', 'Message structure failed')
   ;
 
-$t->get_ok('/corpus/brokenerr2/X/X/p0-1')
+$t->get_ok('/corpus/brokenerr2/X/X/p0-1?format=json')
   ->status_is(417)
   ->json_is('/notifications/0/0', 'error')
   ->json_is('/notifications/0/1', 'Message structure failed')
   ;
 
 # Get from cache
-$t->get_ok('/corpus/WPD15/232/39681/p2133-2134?spans=false&foundry=*')
+$t->get_ok('/corpus/WPD15/232/39681/p2133-2134?spans=false&foundry=*&format=json')
   ->status_is(200)
   ->json_is('/textSigle', 'WPD15/232/39681')
   ->json_like('/snippet', qr!<span class=\"context-left\">!)
@@ -99,10 +95,17 @@
   ;
 
 # Check for validation error
-$t->get_ok('/corpus/WPD15/232/39681/p2133-2134?spans=no')
+$t->get_ok('/corpus/WPD15/232/39681/p2133-2134?spans=no&format=json')
   ->status_is(400)
   ->json_is('/notifications/0/1', 'Parameter "spans" invalid')
   ;
 
+$t->get_ok('/corpus/WPD15/232/39681/p2133-2134?spans=no&format=html')
+  ->status_is(400)
+  ->text_is('p.no-results', 'Unable to perform the action.')
+  ->text_is('div.notify', 'Parameter "spans" invalid')
+  ;
+
+
 done_testing;
 __END__
diff --git a/t/plugin/auth-oauth.t b/t/plugin/auth-oauth.t
index 93cd3ca..8556319 100644
--- a/t/plugin/auth-oauth.t
+++ b/t/plugin/auth-oauth.t
@@ -330,7 +330,7 @@
 # Query without cache
 # The token is invalid and can't be refreshed!
 $t->get_ok('/?q=baum&cutoff=true')
-  ->status_is(200)
+  ->status_is(400)
   ->session_hasnt('/auth')
   ->session_hasnt('/auth_r')
   ->text_is('#error','')
@@ -373,7 +373,7 @@
 
 # The token is invalid and can't be refreshed!
 $csrf = $t->get_ok('/?q=baum&cutoff=true')
-  ->status_is(200)
+  ->status_is(400)
   ->session_hasnt('/auth')
   ->session_hasnt('/auth_r')
   ->text_is('#error','')
diff --git a/t/query.t b/t/query.t
index 035560d..f3b83a6 100644
--- a/t/query.t
+++ b/t/query.t
@@ -86,7 +86,7 @@
   ->element_exists('.notify-error')
   ->text_is('.notify-error', '302: Parantheses/brackets unbalanced.')
   ->content_like(qr!KorAP\.koralQuery =!)
-  ->text_is('.no-results:nth-of-type(1)', 'Unable to perform the search.')
+  ->text_is('.no-results:nth-of-type(1)', 'Unable to perform the action.')
   ;
 
 
diff --git a/templates/failure.html.ep b/templates/failure.html.ep
index 9155c31..e1f6fb3 100644
--- a/templates/failure.html.ep
+++ b/templates/failure.html.ep
@@ -1,4 +1,4 @@
-% layout 'main', title => loc('searchtitle', q => stash('q'), ql => stash('ql')), schematype => 'SearchResultsPage';
+% layout 'main', schematype => 'SearchResultsPage';
 
 <div id="resultinfo"><p class="found"></p></div>
 
@@ -7,6 +7,7 @@
 <div id="search"></div>
 
 <p class="no-results"><%= loc('notIssued') %></p>
+
 % if (stash('err_msg')) {
 <p class="no-results"><%== loc(stash('err_msg'),stash('err_msg')) %></p>
 % }
diff --git a/templates/match_info.html.ep b/templates/match_info.html.ep
new file mode 100644
index 0000000..7731b03
--- /dev/null
+++ b/templates/match_info.html.ep
@@ -0,0 +1,11 @@
+% layout 'main', title => 'Match';
+
+<div id="resultinfo" class="found"></div>
+
+%= include 'query'
+
+<div id="search">
+  <ol class="align-left">
+%= include 'match', match => stash('results');
+  </ol>
+</div>
diff --git a/templates/search.html.ep b/templates/search.html.ep
index a349401..e73b267 100644
--- a/templates/search.html.ep
+++ b/templates/search.html.ep
@@ -1,4 +1,4 @@
-% layout 'main', title => loc('searchtitle', q => stash('q'), ql => stash('ql')), schematype => 'SearchResultsPage';
+% layout 'main', schematype => 'SearchResultsPage';
 
 <div id="resultinfo" <% if (stash('results')->size) { %> class="found"<%} %>>
   <div id="pagination"><%= pagination(stash('start_page'), stash('total_pages'), url_with->query({'p' => '{page}'})) =%></div>