From 0d4163e0a2cfec1ab2dea9dcba6eaa33f5a25ae5 Mon Sep 17 00:00:00 2001
From: riking <rikingcoding@gmail.com>
Date: Mon, 16 Jun 2014 11:25:33 -0700
Subject: [PATCH] FEATURE: Nice error handling page

---
 .../discourse/controllers/exception.js.es6    | 107 ++++++++++++++++++
 .../discourse/controllers/topic_controller.js |  10 ++
 .../javascripts/discourse/mixins/ajax.js      |   2 +
 .../discourse/models/post_stream.js           |   1 +
 .../discourse/routes/application_route.js     |  13 +++
 .../discourse/routes/application_routes.js    |   3 +
 .../discourse/routes/exception_route.js       |  13 +++
 .../discourse/routes/topic_route.js           |   2 +-
 .../templates/exception.js.handlebars         |  24 ++++
 .../discourse/templates/topic.js.handlebars   |  14 ++-
 .../stylesheets/common/base/exception.scss    |  30 +++++
 app/assets/stylesheets/desktop/topic.scss     |  17 +++
 app/assets/stylesheets/mobile/topic.scss      |  16 +++
 app/controllers/forums_controller.rb          |   4 +
 config/locales/client.en.yml                  |  15 +++
 config/routes.rb                              |   1 +
 16 files changed, 265 insertions(+), 7 deletions(-)
 create mode 100644 app/assets/javascripts/discourse/controllers/exception.js.es6
 create mode 100644 app/assets/javascripts/discourse/routes/exception_route.js
 create mode 100644 app/assets/javascripts/discourse/templates/exception.js.handlebars
 create mode 100644 app/assets/stylesheets/common/base/exception.scss

diff --git a/app/assets/javascripts/discourse/controllers/exception.js.es6 b/app/assets/javascripts/discourse/controllers/exception.js.es6
new file mode 100644
index 000000000..e6b8ae881
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/exception.js.es6
@@ -0,0 +1,107 @@
+
+var ButtonBackBright = {
+      classes: "btn-primary",
+      action: "back",
+      key: "errors.buttons.back"
+    },
+    ButtonBackDim = {
+      classes: "",
+      action: "back",
+      key: "errors.buttons.back"
+    },
+    ButtonTryAgain = {
+      classes: "btn-primary",
+      action: "tryLoading",
+      key: "errors.buttons.again"
+    },
+    ButtonLoadPage = {
+      classes: "btn-primary",
+      action: "tryLoading",
+      key: "errors.buttons.fixed"
+    };
+
+/**
+  The controller for the nice error page
+
+  @class ExceptionController
+  @extends Discourse.ObjectController
+  @namespace Discourse
+  @module Discourse
+**/
+export default Discourse.ObjectController.extend({
+  thrown: null,
+  lastTransition: null,
+
+  isNetwork: function() {
+    // never made it on the wire
+    if (this.get('thrown.readyState') === 0) return true;
+    // timed out
+    if (this.get('thrown.jqTextStatus') === "timeout") return true;
+    return false;
+  }.property(),
+  isServer: Em.computed.gte('thrown.status', 500),
+  isUnknown: Em.computed.none('isNetwork', 'isServer'),
+
+  // TODO
+  // make ajax requests to /srv/status with exponential backoff
+  // if one succeeds, set networkFixed to true, which puts a "Fixed!" message on the page
+  networkFixed: false,
+  loading: false,
+
+  _init: function() {
+    this.set('loading', false);
+  }.on('init'),
+
+  reason: function() {
+    if (this.get('isNetwork')) {
+      return I18n.t('errors.reasons.network');
+    } else if (this.get('isServer')) {
+      return I18n.t('errors.reasons.server');
+    } else {
+      // TODO
+      return I18n.t('errors.reasons.unknown');
+    }
+  }.property('isNetwork', 'isServer', 'isUnknown'),
+
+  requestUrl: Em.computed.alias('thrown.requestedUrl'),
+
+  desc: function() {
+    if (this.get('networkFixed')) {
+      return I18n.t('errors.desc.network_fixed');
+    } else if (this.get('isNetwork')) {
+      return I18n.t('errors.desc.network');
+    } else if (this.get('isServer')) {
+      return I18n.t('errors.desc.server', this.get('thrown.statusText'));
+    } else {
+      // TODO
+      return I18n.t('errors.desc.unknown');
+    }
+  }.property('networkFixed', 'isNetwork', 'isServer', 'isUnknown'),
+
+  enabledButtons: function() {
+    if (this.get('networkFixed')) {
+      return [ButtonLoadPage];
+    } else if (this.get('isNetwork')) {
+      return [ButtonBackDim, ButtonTryAgain];
+    } else if (this.get('isServer')) {
+      return [ButtonBackBright];
+    } else {
+      return [ButtonBackBright, ButtonTryAgain];
+    }
+  }.property('networkFixed', 'isNetwork', 'isServer', 'isUnknown'),
+
+  actions: {
+    back: function() {
+      window.history.back();
+    },
+
+    tryLoading: function() {
+      this.set('loading', true);
+      var self = this;
+      Em.run.schedule('afterRender', function() {
+        self.get('lastTransition').retry();
+        self.set('loading', false);
+      });
+    }
+  }
+});
diff --git a/app/assets/javascripts/discourse/controllers/topic_controller.js b/app/assets/javascripts/discourse/controllers/topic_controller.js
index 2834cfb49..15f9c1b59 100644
--- a/app/assets/javascripts/discourse/controllers/topic_controller.js
+++ b/app/assets/javascripts/discourse/controllers/topic_controller.js
@@ -385,6 +385,16 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
       });
     },
 
+    retryLoading: function() {
+      var self = this;
+      self.set('retrying', true);
+      this.get('postStream').refresh().then(function() {
+        self.set('retrying', false);
+      }, function() {
+        self.set('retrying', false);
+      });
+    },
+
     toggleWiki: function(post) {
       post.toggleProperty('wiki');
     }
diff --git a/app/assets/javascripts/discourse/mixins/ajax.js b/app/assets/javascripts/discourse/mixins/ajax.js
index f6cdbdbf7..a3ed93dc6 100644
--- a/app/assets/javascripts/discourse/mixins/ajax.js
+++ b/app/assets/javascripts/discourse/mixins/ajax.js
@@ -65,7 +65,9 @@ Discourse.Ajax = Em.Mixin.create({
         // If it's a parsererror, don't reject
         if (xhr.status === 200) return args.success(xhr);
 
+        // Fill in some extra info
         xhr.jqTextStatus = textStatus;
+        xhr.requestedUrl = url;
 
         Ember.run(promise, promise.reject, xhr);
         if (oldError) oldError(xhr);
diff --git a/app/assets/javascripts/discourse/models/post_stream.js b/app/assets/javascripts/discourse/models/post_stream.js
index 7ec488c73..a8ff38bf3 100644
--- a/app/assets/javascripts/discourse/models/post_stream.js
+++ b/app/assets/javascripts/discourse/models/post_stream.js
@@ -249,6 +249,7 @@ Discourse.PostStream = Em.Object.extend({
       self.setProperties({ loadingFilter: false, loaded: true });
     }).catch(function(result) {
       self.errorLoading(result);
+      throw result;
     });
   },
   hasLoadedData: Em.computed.and('hasPosts', 'hasStream'),
diff --git a/app/assets/javascripts/discourse/routes/application_route.js b/app/assets/javascripts/discourse/routes/application_route.js
index 9d599853a..5d0a1ef6e 100644
--- a/app/assets/javascripts/discourse/routes/application_route.js
+++ b/app/assets/javascripts/discourse/routes/application_route.js
@@ -10,6 +10,19 @@ Discourse.ApplicationRoute = Em.Route.extend({
 
   actions: {
 
+    error: function(err, transition) {
+      if (err.status === 404) {
+        // 404
+        this.intermediateTransitionTo('unknown');
+        return;
+      }
+
+      var exceptionController = this.controllerFor('exception');
+      exceptionController.setProperties({ lastTransition: transition, thrown: err });
+
+      this.intermediateTransitionTo('exception');
+    },
+
     showLogin: function() {
       if (Discourse.get("isReadOnly")) {
         bootbox.alert(I18n.t("read_only_mode.login_disabled"));
diff --git a/app/assets/javascripts/discourse/routes/application_routes.js b/app/assets/javascripts/discourse/routes/application_routes.js
index 813528d26..05eb76927 100644
--- a/app/assets/javascripts/discourse/routes/application_routes.js
+++ b/app/assets/javascripts/discourse/routes/application_routes.js
@@ -13,6 +13,9 @@ Discourse.Route.buildRoutes(function() {
     router.route(page, { path: '/' + page });
   });
 
+  // Error page
+  this.route('exception', { path: '/exception' });
+
   // Topic routes
   this.resource('topic', { path: '/t/:slug/:id' }, function() {
     this.route('fromParams', { path: '/' });
diff --git a/app/assets/javascripts/discourse/routes/exception_route.js b/app/assets/javascripts/discourse/routes/exception_route.js
new file mode 100644
index 000000000..8640600e0
--- /dev/null
+++ b/app/assets/javascripts/discourse/routes/exception_route.js
@@ -0,0 +1,13 @@
+/**
+  Client-side pseudo-route for showing an error page.
+
+  @class ExceptionRoute
+  @extends Discourse.Route
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.ExceptionRoute = Discourse.Route.extend({
+  serialize: function() {
+    return "";
+  }
+});
diff --git a/app/assets/javascripts/discourse/routes/topic_route.js b/app/assets/javascripts/discourse/routes/topic_route.js
index 0d0a097e7..5722b19ff 100644
--- a/app/assets/javascripts/discourse/routes/topic_route.js
+++ b/app/assets/javascripts/discourse/routes/topic_route.js
@@ -89,7 +89,7 @@ Discourse.TopicRoute = Discourse.Route.extend({
       }
     }, 150),
 
-    willTransition: function() { this.set("isTransitioning", true); }
+    willTransition: function() { this.set("isTransitioning", true); return true; }
 
   },
 
diff --git a/app/assets/javascripts/discourse/templates/exception.js.handlebars b/app/assets/javascripts/discourse/templates/exception.js.handlebars
new file mode 100644
index 000000000..63ff0b64b
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/exception.js.handlebars
@@ -0,0 +1,24 @@
+<div class="container">
+  <div class="error-page">
+    <div class="face">:(</div>
+    <div class="reason">{{reason}}</div>
+    <div class="url">
+      {{i18n errors.prev_page}} <a {{bind-attr href=requestUrl}} data-auto-route="true">{{requestUrl}}</a>
+    </div>
+    <div class="desc">
+      {{#if networkFixed}}
+        <i class="fa fa-check-circle"></i>
+      {{/if}}
+
+      {{desc}}
+    </div>
+    <div class="buttons">
+      {{#each buttonData in enabledButtons}}
+        <button class="btn {{unbound buttonData.classes}}" {{action buttonData.action}}>{{boundI18n buttonData.key}}</button>
+      {{/each}}
+      {{#if loading}}
+        <i class="fa fa-spin fa-spinner"></i>
+      {{/if}}
+    </div>
+  </div>
+</div>
diff --git a/app/assets/javascripts/discourse/templates/topic.js.handlebars b/app/assets/javascripts/discourse/templates/topic.js.handlebars
index 0ecc43dd3..ca9b2de6f 100644
--- a/app/assets/javascripts/discourse/templates/topic.js.handlebars
+++ b/app/assets/javascripts/discourse/templates/topic.js.handlebars
@@ -117,17 +117,19 @@
 {{else}}
   {{#if hasError}}
     <div class='container'>
-      {{#if errorBodyHtml}}
+      <div class="topic-error">
         {{{errorBodyHtml}}}
-      {{/if}}
 
-      {{#if message}}
-        <div class="message">
-          <h2>{{message}}</h2>
+        {{#if message}}
+          {{message}}
           {{#unless currentUser}}
             <button {{action showLogin}} class='btn btn-primary btn-small'>{{i18n log_in}}</button>
           {{/unless}}
-        </div>
+        {{/if}}
+        <button class="btn btn-primary topic-retry" {{action retryLoading}}>{{i18n errors.buttons.again}}</button>
+      </div>
+      {{#if retrying}}
+        <div class='spinner'>{{i18n loading}}</div>
       {{/if}}
     </div>
   {{else}}
diff --git a/app/assets/stylesheets/common/base/exception.scss b/app/assets/stylesheets/common/base/exception.scss
new file mode 100644
index 000000000..a1d411981
--- /dev/null
+++ b/app/assets/stylesheets/common/base/exception.scss
@@ -0,0 +1,30 @@
+.error-page {
+  text-align: center;
+  padding-top: 2em;
+
+  .face {
+    font-size: 60px;
+    height: 60px;
+  }
+  .reason {
+    font-size: 24px;
+    height: 24px;
+  }
+  .url {
+    font-style: italic;
+    font-size: 11px;
+  }
+  .desc {
+    margin-top: 16px;
+    .fa-check-circle {
+      color: $success;
+    }
+  }
+  .buttons {
+    margin-top: 15px;
+
+    button {
+      margin: 0 20px;
+    }
+  }
+}
diff --git a/app/assets/stylesheets/desktop/topic.scss b/app/assets/stylesheets/desktop/topic.scss
index 7e6542acf..858ac9cb9 100644
--- a/app/assets/stylesheets/desktop/topic.scss
+++ b/app/assets/stylesheets/desktop/topic.scss
@@ -84,6 +84,23 @@ a:hover.reply-new {
   }
 }
 
+.topic-error {
+  padding: 18px;
+  width: 60%;
+  margin-left: auto;
+  margin-right: auto;
+  font-size: 24px;
+  text-align: center;
+  line-height: 1.1em;
+
+  .topic-retry {
+    display: block;
+    margin-top: 28px;
+    margin-left: auto;
+    margin-right: auto;
+  }
+}
+
 #topic-closing-info {
   border-top: 1px solid scale-color-diff();
   padding-top: 10px;
diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss
index 0fbf32666..57d79cfa9 100644
--- a/app/assets/stylesheets/mobile/topic.scss
+++ b/app/assets/stylesheets/mobile/topic.scss
@@ -146,6 +146,22 @@
   }
 }
 
+.topic-error {
+  padding: 18px;
+  width: 90%;
+  margin-left: auto;
+  margin-right: auto;
+  font-size: 24px;
+  line-height: 1.1em;
+
+  .topic-retry {
+    display: block;
+    margin-top: 20px;
+    margin-left: auto;
+    margin-right: auto;
+  }
+}
+
 #topic-progress-wrapper.docked {
   position: absolute;
 }
diff --git a/app/controllers/forums_controller.rb b/app/controllers/forums_controller.rb
index a15b6090e..32f7f9b2c 100644
--- a/app/controllers/forums_controller.rb
+++ b/app/controllers/forums_controller.rb
@@ -16,4 +16,8 @@ class ForumsController < ApplicationController
     raise "WAT - #{Time.now.to_s}"
   end
 
+  def home_redirect
+    redirect_to '/'
+  end
+
 end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index c2098561b..8cf29ff5f 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -468,6 +468,21 @@ en:
         the_topic: "the topic"
 
     loading: "Loading..."
+    errors:
+      prev_page: "while trying to load"
+      reasons:
+        network: "Network Error"
+        server: "Server Error: {{code}}"
+        unknown: "Error"
+      desc:
+        network: "Please check your connection."
+        network_fixed: "Looks like it's back."
+        server: "Something went wrong."
+        unknown: "Something went wrong."
+      buttons:
+        back: "Go Back"
+        again: "Try Again"
+        fixed: "Load Page"
     close: "Close"
     assets_changed_confirm: "This site was just updated. Refresh now for the latest version?"
     read_only_mode:
diff --git a/config/routes.rb b/config/routes.rb
index ef282eabb..d43e86350 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -377,6 +377,7 @@ Discourse::Application.routes.draw do
   get "onebox" => "onebox#show"
 
   get "error" => "forums#error"
+  get "exception" => "list#latest"
 
   get "message-bus/poll" => "message_bus#poll"