Show network and server errors in a toast 🍞 notification

gr-error-manager is introduced to deal with error events
fired from gr-rest-api-interface. Currently it will display
the first error it sees for 5 seconds, ignoring others during
that time. If a response comes back as a 403, it prompts the
user to refresh the page and the alert remains on the screen
indefinitely.

Bug: Issue 3992
Bug: Issue 3953
Change-Id: I4a54eb1e865b88f9a5531e864e0b1d58d638a4cd
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 47324ba..be536da 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -141,16 +141,13 @@
       this.disabled = true;
       this._saveReview(obj).then(function(response) {
         this.disabled = false;
-        if (!response.ok) {
-          alert('Oops. Something went wrong. Check the console and bug the ' +
-              'PolyGerrit team for assistance.');
-          return response.text().then(function(text) {
-            console.error(text);
-          });
-        }
+        if (!response.ok) { return response; }
 
         this.draft = '';
         this.fire('send', null, {bubbles: false});
+      }.bind(this)).catch(function(err) {
+        this.disabled = false;
+        throw err;
       }.bind(this));
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index 09ce7d7..de99039 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -144,11 +144,8 @@
       this._xhrPromise =
           this._removeReviewer(accountID).then(function(response) {
         this.disabled = false;
-        if (!response.ok) {
-          return response.text().then(function(text) {
-            alert(text);
-          });
-        }
+        if (!response.ok) { return response; }
+
         var reviewers = this.change.reviewers;
         ['REVIEWER', 'CC'].forEach(function(type) {
           reviewers[type] = reviewers[type] || [];
@@ -159,6 +156,9 @@
             }
           }
         }, this);
+      }.bind(this)).catch(function(err) {
+        this.disabled = false;
+        throw err;
       }.bind(this));
     },
 
@@ -304,11 +304,8 @@
       this._xhrPromise = this._addReviewer(reviewerID).then(function(response) {
         this.change.reviewers['CC'] = this.change.reviewers['CC'] || [];
         this.disabled = false;
-        if (!response.ok) {
-          return response.text().then(function(text) {
-            alert(text);
-          });
-        }
+        if (!response.ok) { return response; }
+
         return this.$.restAPI.getResponseObject(response).then(function(obj) {
           obj.reviewers.forEach(function(r) {
             this.push('change.removable_reviewers', r);
@@ -317,6 +314,9 @@
           this._inputVal = '';
           this.$.input.focus();
         }.bind(this));
+      }.bind(this)).catch(function(err) {
+        this.disabled = false;
+        throw err;
       }.bind(this));
     },
 
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
new file mode 100644
index 0000000..80f293d
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -0,0 +1,27 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-alert/gr-alert.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-error-manager">
+  <template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-error-manager.js"></script>
+</dom-module>
+
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
new file mode 100644
index 0000000..757d79f
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -0,0 +1,109 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-error-manager',
+
+    properties: {
+      _alertElement: Element,
+      _hideAlertHandle: Number,
+      _hideAlertTimeout: {
+        type: Number,
+        value: 5000,
+      },
+    },
+
+    attached: function() {
+      this.listen(document, 'server-error', '_handleServerError');
+      this.listen(document, 'network-error', '_handleNetworkError');
+    },
+
+    detached: function() {
+      this._clearHideAlertHandle();
+      this.unlisten(document, 'server-error', '_handleServerError');
+      this.unlisten(document, 'network-error', '_handleNetworkError');
+    },
+
+    _handleServerError: function(e) {
+      if (e.detail.response.status === 403) {
+        this._getLoggedIn().then(function(loggedIn) {
+          if (loggedIn) {
+            // The app was logged at one point and is now getting auth errors.
+            // This indicates the auth token is no longer valid.
+            this._showAuthErrorAlert();
+          }
+        }.bind(this));
+      } else {
+        e.detail.response.text().then(function(text) {
+          this._showAlert('Server error: ' + text);
+        }.bind(this));
+      }
+    },
+
+    _handleNetworkError: function(e) {
+      this._showAlert('Server unavailable');
+      console.error(e.detail.error.message);
+    },
+
+    _getLoggedIn: function() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _showAlert: function(text) {
+      if (this._alertElement) { return; }
+
+      this._clearHideAlertHandle();
+      this._hideAlertHandle =
+            this.async(this._hideAlert.bind(this), this._hideAlertTimeout);
+      var el = this._createToastAlert();
+      el.show(text);
+      this._alertElement = el;
+    },
+
+    _hideAlert: function() {
+      if (!this._alertElement) { return; }
+
+      this._alertElement.hide();
+      this._alertElement = null;
+    },
+
+    _clearHideAlertHandle: function() {
+      if (this._hideAlertHandle != null) {
+        this.cancelAsync(this._hideAlertHandle);
+        this._hideAlertHandle = null;
+      }
+    },
+
+    _showAuthErrorAlert: function() {
+      if (this._alertElement) { return; }
+
+      var el = this._createToastAlert();
+      el.addEventListener('action', this._refreshPage.bind(this));
+      el.show('Auth error', 'Refresh page');
+      this._alertElement = el;
+    },
+
+    _createToastAlert: function() {
+      var el = document.createElement('gr-alert');
+      el.toast = true;
+      return el;
+    },
+
+    _refreshPage: function() {
+      window.location.reload();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
new file mode 100644
index 0000000..98f79b0
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -0,0 +1,85 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-error-manager</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-error-manager.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-error-manager></gr-error-manager>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-error-manager tests', function() {
+    var element;
+
+    setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(true); },
+      });
+      element = fixture('basic');
+    });
+
+    test('show auth error', function(done) {
+      var showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      element.fire('server-error', {response: {status: 403}});
+      flush(function() {
+        assert.isTrue(showAuthErrorStub.calledOnce);
+        showAuthErrorStub.restore();
+        done();
+      });
+    });
+
+    test('show normal server error', function(done) {
+      var showAlertStub = sinon.stub(element, '_showAlert');
+      element.fire('server-error', {response: {
+        status: 500,
+        text: function() { return Promise.resolve('ZOMG'); },
+      }});
+      flush(function() {
+        assert.isTrue(showAlertStub.calledOnce);
+        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
+            'Server error: ZOMG'));
+        showAlertStub.restore();
+        done();
+      });
+    });
+
+    test('show network error', function(done) {
+      var consoleErrorStub = sinon.stub(console, 'error');
+      var showAlertStub = sinon.stub(element, '_showAlert');
+      element.fire('network-error', {error: new Error('ZOMG')});
+      flush(function() {
+        assert.isTrue(showAlertStub.calledOnce);
+        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
+            'Server unavailable'));
+        assert.isTrue(consoleErrorStub.calledOnce);
+        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
+        showAlertStub.restore();
+        consoleErrorStub.restore();
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index b39295a..ded5108 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -74,13 +74,8 @@
       this.disabled = true;
       this._xhrPromise = this._saveDraft(this.comment).then(function(response) {
         this.disabled = false;
-        if (!response.ok) {
-          alert('Your draft couldn’t be saved. Check the console and contact ' +
-              'the PolyGerrit team for assistance.');
-          return response.text().then(function(text) {
-            console.error(text);
-          });
-        }
+        if (!response.ok) { return response; }
+
         return this.$.restAPI.getResponseObject(response).then(function(obj) {
           var comment = obj;
           comment.__draft = true;
@@ -95,10 +90,8 @@
           return obj;
         }.bind(this));
       }.bind(this)).catch(function(err) {
-        alert('Your draft couldn’t be saved. Check the console and contact ' +
-            'the PolyGerrit team for assistance.');
         this.disabled = false;
-        console.error(err.message);
+        throw err;
       }.bind(this));
     },
 
@@ -198,18 +191,12 @@
       this._xhrPromise =
           this._deleteDraft(this.comment).then(function(response) {
         this.disabled = false;
-        if (!response.ok) {
-          alert('Your draft couldn’t be deleted. Check the console and ' +
-              'contact the PolyGerrit team for assistance.');
-          return response.text().then(function(text) {
-            console.error(text);
-          });
-        }
+        if (!response.ok) { return response; }
+
         this.fire('comment-discard');
       }.bind(this)).catch(function(err) {
-        alert('Your draft couldn’t be deleted. Check the console and contact ' +
-            'the PolyGerrit team for assistance.');
         this.disabled = false;
+        throw err;
       }.bind(this));;
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 85371f4..b2adcf4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -344,14 +344,11 @@
       el.disabled = true;
       this._saveDiffPreferences().then(function(response) {
         el.disabled = false;
-        if (!response.ok) {
-          alert('Oops. Something went wrong. Check the console and bug the ' +
-              'PolyGerrit team for assistance.');
-          return response.text().then(function(text) {
-            console.error(text);
-          });
-        }
+        if (!response.ok) { return response; }
+
         this.$.prefsOverlay.close();
+      }.bind(this)).catch(function(err) {
+        el.disabled = false;
       }.bind(this));
     },
 
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 95b8ae6..c7ff59f 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -18,6 +18,7 @@
 <link rel="import" href="../behaviors/keyboard-shortcut-behavior.html">
 <link rel="import" href="../styles/app-theme.html">
 
+<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
 <link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
 <link rel="import" href="./core/gr-main-header/gr-main-header.html">
 <link rel="import" href="./core/gr-router/gr-router.html">
@@ -139,6 +140,7 @@
     <template is="dom-repeat" items="[[_serverConfig.plugin.js_resource_paths]]" as="path">
       <script src$="/[[path]]" defer></script>
     </template>
+    <gr-error-manager></gr-error-manager>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-app.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index 28e45a4..140fbaa 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -39,6 +39,14 @@
       :host([shown]) {
         transform: translateY(0);
       }
+      .text {
+        display: inline-block;
+        max-width: 25em;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        vertical-align: bottom;
+        white-space: nowrap;
+      }
       .action {
         color: #a1c2fa;
         font-weight: bold;
@@ -46,11 +54,11 @@
         text-decoration: none;
       }
     </style>
-    [[text]]
+    <span class="text">[[text]]</span>
     <gr-button
         link
         class="action"
-        hidden$="[[!actionText]]"
+        hidden$="[[_hideActionButton]]"
         on-tap="_handleActionTap">[[actionText]]</gr-button>
   </template>
   <script src="gr-alert.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
index ba481ec..a3e933f 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -38,6 +38,7 @@
         reflectToAttribute: true,
       },
 
+      _hideActionButton: Boolean,
       _boundTransitionEndHandler: {
         type: Function,
         value: function() { return this._handleTransitionEnd.bind(this); },
@@ -56,6 +57,7 @@
     show: function(text, opt_actionText) {
       this.text = text;
       this.actionText = opt_actionText;
+      this._hideActionButton = !opt_actionText
       document.body.appendChild(this);
       this._setShown(true);
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index e64e9dc..23c56b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -38,18 +38,7 @@
       var newVal = !this.change.starred;
       this.set('change.starred', newVal);
       this._xhrPromise = this.$.restAPI.saveChangeStarred(this.change._number,
-          newVal).then(function(response) {
-            if (!response.ok) {
-              return response.text().then(function(text) {
-                throw Error(text);
-              });
-            }
-          }).catch(function(err) {
-            this.set('change.starred', !newVal);
-            alert('Change couldn’t be starred. Check the console and contact ' +
-                'the PolyGerrit team for assistance.');
-            throw err;
-      }.bind(this));
+          newVal);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 82e045e..1ada7e9 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -68,6 +68,18 @@
   Polymer({
     is: 'gr-rest-api-interface',
 
+    /**
+     * Fired when an server error occurs.
+     *
+     * @event server-error
+     */
+
+    /**
+     * Fired when a network error occurs.
+     *
+     * @event network-error
+     */
+
     properties: {
       _cache: {
         type: Object,
@@ -95,14 +107,24 @@
           return;
         }
 
-        if (!response.ok && opt_errFn) {
-          opt_errFn.call(null, response);
-          return undefined;
+        if (!response.ok) {
+          if (opt_errFn) {
+            opt_errFn.call(null, response);
+            return undefined;
+          }
+          this.fire('server-error', {response: response});
         }
+
         return this.getResponseObject(response);
       }.bind(this)).catch(function(err) {
+        if (opt_errFn) {
+          opt_errFn.call(null, null, err);
+        } else {
+          this.fire('network-error', {error: err});
+          throw err;
+        }
         throw err;
-      });
+      }.bind(this));
     },
 
     _urlWithParams: function(url, opt_params) {
@@ -503,13 +525,24 @@
         }
         options.body = opt_body;
       }
-      return fetch(url, options).catch(function(err) {
+      return fetch(url, options).then(function(response) {
+        if (!response.ok) {
+          if (opt_errFn) {
+            opt_errFn.call(null, response);
+            return undefined;
+          }
+          this.fire('server-error', {response: response});
+        }
+
+        return response;
+      }.bind(this)).catch(function(err) {
+        this.fire('network-error', {error: err});
         if (opt_errFn) {
-          opt_errFn.call(opt_ctx || this);
+          opt_errFn.call(opt_ctx, null, err);
         } else {
           throw err;
         }
-      });
+      }.bind(this));
     },
 
     getDiff: function(changeNum, basePatchNum, patchNum, path,
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index dc12bf9..9d16b84 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -38,6 +38,7 @@
     '../elements/change-list/gr-change-list/gr-change-list_test.html',
     '../elements/change-list/gr-change-list-item/gr-change-list-item_test.html',
     '../elements/core/gr-account-dropdown/gr-account-dropdown_test.html',
+    '../elements/core/gr-error-manager/gr-error-manager_test.html',
     '../elements/core/gr-main-header/gr-main-header_test.html',
     '../elements/core/gr-search-bar/gr-search-bar_test.html',
     '../elements/diff/gr-diff/gr-diff-builder_test.html',
@@ -48,9 +49,9 @@
     '../elements/diff/gr-diff-preferences/gr-diff-preferences_test.html',
     '../elements/diff/gr-diff-view/gr-diff-view_test.html',
     '../elements/diff/gr-patch-range-select/gr-patch-range-select_test.html',
-    '../elements/shared/gr-alert/gr-alert_test.html',
     '../elements/shared/gr-account-label/gr-account-label_test.html',
     '../elements/shared/gr-account-link/gr-account-link_test.html',
+    '../elements/shared/gr-alert/gr-alert_test.html',
     '../elements/shared/gr-avatar/gr-avatar_test.html',
     '../elements/shared/gr-change-star/gr-change-star_test.html',
     '../elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html',