Make network error handling more generic

This commit is contained in:
Scott Erickson 2016-01-25 16:52:14 -08:00
parent cef0ed0185
commit 29350bf1de
12 changed files with 315 additions and 89 deletions

View file

@ -142,7 +142,7 @@ module.exports = class Tracker
$.post("#{window.location.protocol or 'http:'}//analytics.codecombat.com/analytics", dataToSend).fail ->
console.error "Analytics post failed!"
else
request = @supermodel.addRequestResource 'log_event', {
request = @supermodel.addRequestResource {
url: '/db/analytics.log.event/-/log_event'
data: {event: event, properties: properties}
method: 'POST'

View file

@ -34,6 +34,11 @@
twitter_follow: "Follow"
teachers: "Teachers"
careers: "Careers"
facebook: "Facebook"
twitter: "Twitter"
create_a_class: "Create a Class"
other: "Other"
learn_to_code: "Learn to Code!"
modal:
close: "Close"
@ -1352,17 +1357,22 @@
loading_error:
could_not_load: "Error loading from server"
connection_failure: "Connection failed."
connection_failure: "Connection Failed" # {change}
login_required: "Login Required"
login_required_desc: " You need to be logged in to access this page."
unauthorized: "You need to be signed in. Do you have cookies disabled?"
forbidden: "You do not have the permissions."
not_found: "Not found."
forbidden: "Forbidden" # {change}
forbidden_desc: "Oh no, theres nothing we can show you here! Make sure youre logged into the correct account, or visit one of the links below to get back to programming!"
not_found: "Not Found" # {change}
not_found_desc: "Hm, theres nothing here. Visit one of the following links to get back to programming!"
not_allowed: "Method not allowed."
timeout: "Server timeout."
timeout: "Server Timeout" # {change}
conflict: "Resource conflict."
bad_input: "Bad input."
server_error: "Server error."
unknown: "Unknown error."
unknown: "Unknown Error" # {change}
error: "ERROR"
general_desc: "Something went wrong, and its probably our fault. Try waiting a bit and then refreshing the page, or visit one of the following links to get back to programming!"
resources:
sessions: "Sessions"

View file

@ -28,6 +28,10 @@ module.exports = class SuperModel extends Backbone.Model
unfinished
loadModel: (model, name, fetchOptions, value=1) ->
# Deprecating name. Handle if name is not included
value = fetchOptions if _.isNumber(fetchOptions)
fetchOptions = name if _.isObject(name)
# hero-ladder levels need remote opponent_session for latest session data (e.g. code)
# Can't apply to everything since other features rely on cached models being more recent (E.g. level_session)
# E.g.#2 heroConfig isn't necessarily saved to db in world map inventory modal, so we need to load the cached session on level start
@ -48,6 +52,10 @@ module.exports = class SuperModel extends Backbone.Model
return res
loadCollection: (collection, name, fetchOptions, value=1) ->
# Deprecating name. Handle if name is not included
value = fetchOptions if _.isNumber(fetchOptions)
fetchOptions = name if _.isObject(name)
url = collection.getURL()
if cachedCollection = @collections[url]
console.debug 'Collection cache hit', url, 'already loaded', cachedCollection.loaded
@ -135,6 +143,10 @@ module.exports = class SuperModel extends Backbone.Model
return @progress is 1.0 or not @denom
addModelResource: (modelOrCollection, name, fetchOptions, value=1) ->
# Deprecating name. Handle if name is not included
value = fetchOptions if _.isNumber(fetchOptions)
fetchOptions = name if _.isObject(name)
modelOrCollection.saveBackups = modelOrCollection.saveBackups or @shouldSaveBackups(modelOrCollection)
@checkName(name)
res = new ModelResource(modelOrCollection, name, fetchOptions, value)
@ -145,20 +157,25 @@ module.exports = class SuperModel extends Backbone.Model
@removeResource _.find(@resources, (resource) -> resource?.model is modelOrCollection)
addRequestResource: (name, jqxhrOptions, value=1) ->
# Deprecating name. Handle if name is not included
value = jqxhrOptions if _.isNumber(jqxhrOptions)
jqxhrOptions = name if _.isObject(name)
@checkName(name)
res = new RequestResource(name, jqxhrOptions, value)
@storeResource(res, value)
return res
addSomethingResource: (name, value=1) ->
value = name if _.isNumber(name)
@checkName(name)
res = new SomethingResource(name, value)
@storeResource(res, value)
return res
checkName: (name) ->
if not name
throw new Error('Resource name should not be empty.')
if _.isString(name)
console.warn("SuperModel name property deprecated. Remove '#{name}' from code.")
storeResource: (resource, value) ->
@rid++

View file

@ -0,0 +1,15 @@
#loading-error
padding: 20px
.btn
margin-top: 20px
.login-btn
margin-right: 10px
#not-found-img
max-width: 20%
margin: 20px 0
#links-row
margin-top: 50px

View file

@ -39,7 +39,7 @@ block header
li
a(href="/account/prepaid", data-i18n="account.prepaid_codes") Prepaid Codes
li
a#logout-button(data-i18n="login.log_out")
a#logout-button(data-i18n="login.log_out")
else
button.btn.btn-sm.btn-primary.header-font.signup-button(data-i18n="login.sign_up")

View file

@ -1,28 +1,89 @@
.alert.alert-danger.loading-error-alert
span(data-i18n="loading_error.could_not_load") Error loading from server
span (
span(data-i18n="resources.#{name}")
span )
if !responseText
strong(data-i18n="loading_error.connection_failure") Connection failed.
else if status === 401
strong(data-i18n="loading_error.unauthorized") You need to be signed in. Do you have cookies disabled?
else if status === 403
strong(data-i18n="loading_error.forbidden") You do not have the permissions.
else if status === 404
strong(data-i18n="loading_error.not_found") Not found.
else if status === 405
strong(data-i18n="loading_error.not_allowed") Method not allowed.
else if status === 408
strong(data-i18n="loading_error.timeout") Server timeout.
else if status === 409
strong(data-i18n="loading_error.conflict") Resource conflict.
else if status === 422
strong(data-i18n="loading_error.bad_input") Bad input.
else if status >= 500
strong(data-i18n="loading_error.server_error") Server error.
#loading-error.text-center
if jqxhr.status === 401
h1
span.spr 401:
span(data-i18n="loading_error.login_required")
p(data-i18n="loading_error.login_required_desc")
button.login-btn.btn.btn-primary(data-i18n="login.log_in")
button#create-account-btn.btn.btn-primary(data-i18n="login.sign_up")
// 402 currently not in use. TODO: Set it up
else if jqxhr.status === 402
h2 402: Payment Required
else if jqxhr.status === 403
h1
span.spr 403:
span(data-i18n="loading_error.forbidden") Forbidden
p(data-i18n="loading_error.forbidden_desc")
// this should make no diff... but sometimes the server returns 403 when it should return 401
if !me.isAnonymous()
button#logout-btn.btn.btn-primary(data-i18n="login.log_out")
else if jqxhr.status === 404
h1
span.spr 404:
span(data-i18n="loading_error.not_found")
- var num = Math.floor(Math.random() * 3) + 1;
img#not-found-img(src="/images/pages/not_found/404_#{num}.png")
p(data-i18n="loading_error.not_found_desc")
else if !jqxhr.status
h1(data-i18n="loading_error.connection_failure")
p It doesnt look like youre connected to the internet! Check your network connection and then reload this page.
else
strong(data-i18n="loading_error.unknown") Unknown error.
if jqxhr.status === 408
h1
span.spr 408:
span(data-i18n="loading_error.timeout")
else if jqxhr.status >= 500 && jqxhr.status <= 599
h1
span.spr #{jqxhr.status}:
span(data-i18n="loading_error.server_error")
else
h1(data-i18n="loading_error.unknown")
p(data-i18n="loading_error.general_desc")
button.btn.btn-xs.retry-loading-resource(data-i18n="common.retry", data-resource-index=resourceIndex) Retry
button.btn.btn-xs.skip-loading-resource(data-i18n="play_level.skip", data-resource-index=resourceIndex) Skip
#links-row.row
.col-sm-3
strong(data-i18n="cmomon.help") Help
br
a(href="/", data-i18n="nav.home")
br
a(href=view.forumLink(), data-i18n="nav.forum")
br
a(tabindex=-1, data-toggle="coco-modal", data-target="core/ContactModal", data-i18n="nav.contact")
br
a(href='/community', data-i18n="nav.community")
.col-sm-3
strong(data-i18n="courses.students")
br
a(href="/courses/students", data-i18n="nav.learn_to_code")
if me.isAnonymous()
br
a.login-btn(data-i18n="login.log_in")
br
a(href="/courses", data-i18n="courses.join_class")
.col-sm-3
strong(data-i18n="nav.teachers")
br
a(href="/schools", data-i18n="about.why_codecombat")
if me.isAnonymous()
br
a.login-btn(data-i18n="login.log_in")
br
a(href="/courses/teachers", data-i18n="nav.create_a_class")
.col-sm-3
strong(data-i18n="nav.other")
br
a(href="http://blog.codecombat.com/", data-i18n="nav.blog")
br
a(href="https://www.facebook.com/codecombat", data-i18n="nav.facebook")
br
a(href="https://twitter.com/codecombat", data-i18n="nav.twitter")

View file

@ -6,7 +6,8 @@ ol.breadcrumb
a(href=path.url)= path.name
li.active= currentFolder
.well.pull-left#test-wrapper
#test-wrapper.well.pull-left
#demo-area
#testing-area
.nav.nav-pills.nav-stacked.pull-right.well#test-nav

View file

@ -3,6 +3,7 @@ utils = require 'core/utils'
CocoClass = require 'core/CocoClass'
loadingScreenTemplate = require 'templates/core/loading'
loadingErrorTemplate = require 'templates/core/loading-error'
auth = require 'core/auth'
lastToggleModalCall = 0
visibleModal = null
@ -16,8 +17,9 @@ module.exports = class CocoView extends Backbone.View
template: -> ''
events:
'click .retry-loading-resource': 'onRetryResource'
'click .skip-loading-resource': 'onSkipResource'
'click #loading-error .login-btn': 'onClickLoadingErrorLoginButton'
'click #loading-error #create-account-btn': 'onClickLoadingErrorCreateAccountButton'
'click #loading-error #logout-btn': 'onClickLoadingErrorLogoutButton'
subscriptions: {}
shortcuts: {}
@ -157,43 +159,25 @@ module.exports = class CocoView extends Backbone.View
onResourceLoadFailed: (e) ->
r = e.resource
return if r.jqxhr?.status is 402 # payment-required failures are handled separately
if r.jqxhr?.status is 0
r.retries ?= 0
r.retries += 1
if r.retries > 20
msg = 'Your computer or our servers appear to be offline. Please try refreshing.'
noty text: msg, layout: 'center', type: 'error', killer: true
return
else
@warnConnectionError()
return _.delay (=> r.load()), 3000
@$el.find('.loading-container .errors').append(loadingErrorTemplate({
status: r.jqxhr?.status
name: r.name
resourceIndex: r.rid,
responseText: r.jqxhr?.responseText
})).i18n()
@$el.find('.progress').hide()
@showError(r.jqxhr)
warnConnectionError: ->
msg = $.i18n.t 'loading_error.connection_failure', defaultValue: 'Connection failed.'
noty text: msg, layout: 'center', type: 'error', killer: true, timeout: 3000
onRetryResource: (e) ->
res = @supermodel.getResource($(e.target).data('resource-index'))
# different views may respond to this call, and not all have the resource to reload
return unless res and res.isFailed
res.load()
@$el.find('.progress').show()
$(e.target).closest('.loading-error-alert').remove()
onSkipResource: (e) ->
res = @supermodel.getResource($(e.target).data('resource-index'))
return unless res and res.isFailed
res.markLoaded()
@$el.find('.progress').show()
$(e.target).closest('.loading-error-alert').remove()
onClickLoadingErrorLoginButton: (e) ->
e.stopPropagation() # Backbone subviews and superviews will handle this call repeatedly otherwise
AuthModal = require 'views/core/AuthModal'
@openModalView(new AuthModal({mode: 'login'}))
onClickLoadingErrorCreateAccountButton: (e) ->
e.stopPropagation()
AuthModal = require 'views/core/AuthModal'
@openModalView(new AuthModal({mode: 'signup'}))
onClickLoadingErrorLogoutButton: (e) ->
e.stopPropagation()
auth.logoutUser()
# Modals
@ -254,6 +238,23 @@ module.exports = class CocoView extends Backbone.View
@_lastLoading.find('.loading-screen').remove()
@_lastLoading.find('>').removeClass('hidden')
@_lastLoading = null
showError: (jqxhr) ->
return unless @_lastLoading?
context = {
jqxhr: jqxhr
view: @
me: me
}
@_lastLoading.find('.loading-screen').replaceWith((loadingErrorTemplate(context)))
@_lastLoading.i18n()
forumLink: ->
link = 'http://discourse.codecombat.com/'
lang = (me.get('preferredLanguage') or 'en-US').split('-')[0]
if lang in ['zh', 'ru', 'es', 'fr', 'pt', 'de', 'nl', 'lt']
link += "c/other-languages/#{lang}"
link
showReadOnly: ->
return if me.isAdmin() or me.isArtisan()

View file

@ -100,13 +100,6 @@ module.exports = class RootView extends CocoView
c.usesSocialMedia = @usesSocialMedia
c
forumLink: ->
link = 'http://discourse.codecombat.com/'
lang = (me.get('preferredLanguage') or 'en-US').split('-')[0]
if lang in ['zh', 'ru', 'es', 'fr', 'pt', 'de', 'nl', 'lt']
link += "c/other-languages/#{lang}"
link
afterRender: ->
if @$el.find('#site-nav').length # hack...
@$el.addClass('site-chrome')

View file

@ -32,23 +32,23 @@ module.exports = class ClassroomView extends RootView
initialize: (options, classroomID) ->
return if me.isAnonymous()
@classroom = new Classroom({_id: classroomID})
@supermodel.loadModel @classroom, 'classroom'
@supermodel.loadModel @classroom
@courses = new CocoCollection([], { url: "/db/course", model: Course})
@courses.comparator = '_id'
@supermodel.loadCollection(@courses, 'courses')
@supermodel.loadCollection(@courses)
@campaigns = new CocoCollection([], { url: "/db/campaign", model: Campaign })
@courses.comparator = '_id'
@supermodel.loadCollection(@campaigns, 'campaigns', { data: { type: 'course' }})
@supermodel.loadCollection(@campaigns, { data: { type: 'course' }})
@courseInstances = new CocoCollection([], { url: "/db/course_instance", model: CourseInstance})
@courseInstances.comparator = 'courseID'
@supermodel.loadCollection(@courseInstances, 'course_instances', { data: { classroomID: classroomID } })
@supermodel.loadCollection(@courseInstances, { data: { classroomID: classroomID } })
@prepaids = new Prepaids()
@prepaids.comparator = '_id'
@prepaids.fetchByCreator(me.id)
@supermodel.loadCollection(@prepaids, 'prepaids')
@supermodel.loadCollection(@prepaids)
@users = new CocoCollection([], { url: "/db/classroom/#{classroomID}/members", model: User })
@users.comparator = (user) => user.broadName().toLowerCase()
@supermodel.loadCollection(@users, 'users')
@supermodel.loadCollection(@users)
@listenToOnce @courseInstances, 'sync', @onCourseInstancesSync
@sessions = new CocoCollection([], { model: LevelSession })
@ -56,7 +56,7 @@ module.exports = class ClassroomView extends RootView
@sessions = new CocoCollection([], { model: LevelSession })
for courseInstance in @courseInstances.models
sessions = new CocoCollection([], { url: "/db/course_instance/#{courseInstance.id}/level_sessions", model: LevelSession })
@supermodel.loadCollection(sessions, 'sessions', { data: { project: ['level', 'playtime', 'creator', 'changed', 'state.complete'].join(' ') } })
@supermodel.loadCollection(sessions, { data: { project: ['level', 'playtime', 'creator', 'changed', 'state.complete'].join(' ') } })
courseInstance.sessions = sessions
sessions.courseInstance = courseInstance
courseInstance.sessionsByUser = {}

View file

@ -31,13 +31,13 @@ module.exports = class CoursesView extends RootView
@courseInstances = new CocoCollection([], { url: "/db/user/#{me.id}/course_instances", model: CourseInstance})
@courseInstances.comparator = (ci) -> return ci.get('classroomID') + ci.get('courseID')
@listenToOnce @courseInstances, 'sync', @onCourseInstancesLoaded
@supermodel.loadCollection(@courseInstances, 'course_instances')
@supermodel.loadCollection(@courseInstances)
@classrooms = new CocoCollection([], { url: "/db/classroom", model: Classroom })
@supermodel.loadCollection(@classrooms, 'classrooms', { data: {memberID: me.id} })
@supermodel.loadCollection(@classrooms, { data: {memberID: me.id} })
@courses = new CocoCollection([], { url: "/db/course", model: Course})
@supermodel.loadCollection(@courses, 'courses')
@supermodel.loadCollection(@courses)
@campaigns = new CocoCollection([], { url: "/db/campaign", model: Campaign })
@supermodel.loadCollection(@campaigns, 'campaigns', { data: { type: 'course' }})
@supermodel.loadCollection(@campaigns, { data: { type: 'course' }})
onCourseInstancesLoaded: ->
map = {}
@ -51,7 +51,7 @@ module.exports = class CoursesView extends RootView
model: LevelSession
})
courseInstance.sessions.comparator = 'changed'
@supermodel.loadCollection(courseInstance.sessions, 'sessions', { data: { project: 'state.complete level.original playtime changed' }})
@supermodel.loadCollection(courseInstance.sessions, { data: { project: 'state.complete level.original playtime changed' }})
@hocCourseInstance = @courseInstances.findWhere({hourOfCode: true})
if @hocCourseInstance

View file

@ -0,0 +1,128 @@
CocoView = require 'views/core/CocoView'
User = require 'models/User'
BlandView = class BlandView extends CocoView
template: -> ''
initialize: ->
@user = new User()
@supermodel.loadModel(@user)
afterRender: ->
@$el.css({
'border': '2px solid black'
'background': 'white'
'padding': '20px'
})
describe 'CocoView', ->
describe 'network error handling', ->
view = null
respond = (code, json) ->
request = jasmine.Ajax.requests.mostRecent()
view.render()
request.respondWith({status: code, responseText: JSON.stringify(json or {})})
beforeEach ->
view = new BlandView()
describe 'when the server returns 401', ->
beforeEach -> respond(401)
it 'shows a login button which opens the AuthModal', ->
button = view.$el.find('.login-btn')
expect(button.length).toBe(1)
spyOn(view, 'openModalView').and.callFake (modal) -> expect(modal.mode).toBe('login')
button.click()
expect(view.openModalView).toHaveBeenCalled()
it 'shows a create account button which opens the AuthModal', ->
button = view.$el.find('#create-account-btn')
expect(button.length).toBe(1)
spyOn(view, 'openModalView').and.callFake (modal) -> expect(modal.mode).toBe('signup')
button.click()
expect(view.openModalView).toHaveBeenCalled()
it 'says "Login Required"', ->
expect(view.$el.text().indexOf('Login Required')).toBeGreaterThan(-1)
it '(demo)', ->
$('#demo-area').append(view.$el)
describe 'when the server returns 402', ->
beforeEach -> respond(402)
it 'does nothing, because it is up to the view to handle payment next steps'
describe 'when the server returns 403', ->
beforeEach -> respond(403)
it 'includes a logout button which logs out the account', ->
button = view.$el.find('#logout-btn')
expect(button.length).toBe(1)
button.click()
request = jasmine.Ajax.requests.mostRecent()
expect(request.url).toBe('/auth/logout')
it '(demo)', ->
$('#demo-area').append(view.$el)
describe 'when the server returns 404', ->
beforeEach -> respond(404)
it 'includes one of the 404 images', ->
img = view.$el.find('#not-found-img')
expect(img.length).toBe(1)
it '(demo)', ->
$('#demo-area').append(view.$el)
describe 'when the server returns 408', ->
beforeEach -> respond(408)
it 'includes "Server Timeout" in the header', ->
expect(view.$el.text().indexOf('Server Timeout')).toBeGreaterThan(-1)
it 'shows a message encouraging refreshing the page or following links', ->
expect(view.$el.text().indexOf('refresh')).toBeGreaterThan(-1)
it '(demo)', ->
$('#demo-area').append(view.$el)
describe 'when no connection is made', ->
beforeEach ->
respond()
it 'shows "Connection Failed"', ->
expect(view.$el.text().indexOf('Connection Failed')).toBeGreaterThan(-1)
it '(demo)', ->
$('#demo-area').append(view.$el)
describe 'when the server returns any other number >= 400', ->
beforeEach -> respond(9001)
it 'includes "Unknown Error" in the header', ->
expect(view.$el.text().indexOf('Unknown Error')).toBeGreaterThan(-1)
it 'shows a message encouraging refreshing the page or following links', ->
expect(view.$el.text().indexOf('refresh')).toBeGreaterThan(-1)
it '(demo)', ->
$('#demo-area').append(view.$el)