diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 6e3aebf3e..7d35d42aa 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -12,6 +12,7 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr manual: "Manual" fork: "Fork" play: "Play" + retry: "Retry" units: second: "second" @@ -602,3 +603,27 @@ module.exports = nativeDescription: "English", englishDescription: "English", tr tutorial: "tutorial" new_to_programming: ". New to programming? Hit our beginner campaign to skill up." so_ready: "I Am So Ready for This" + + loading_error: + could_not_load: "Error loading from server" + connection_failure: "Connection failed." + unauthorized: "You need to be signed in. Do you have cookies disabled?" + forbidden: "You do not have the permissions." + not_found: "Not found." + not_allowed: "Method not allowed." + timeout: "Server timeout." + conflict: "Resource conflict." + bad_input: "Bad input." + server_error: "Server error." + unknown: "Unknown error." + + resources: + your_sessions: "Your Sessions" + level: "Level" + social_network_apis: "Social Network APIs" + facebook_status: "Facebook Status" + facebook_friends: "Facebook Friends" + facebook_friend_sessions: "Facebook Friend Sessions" + gplus_friends: "G+ Friends" + gplus_friend_sessions: "G+ Friend Sessions" + leaderboard: 'leaderboard' \ No newline at end of file diff --git a/app/styles/base.sass b/app/styles/base.sass index fc6e45c03..0e9e2c8ee 100644 --- a/app/styles/base.sass +++ b/app/styles/base.sass @@ -167,7 +167,15 @@ a[data-toggle="modal"] width: 50% margin: 0 25% .progress-bar - width: 100% + width: 0% + transition: width 0.1s ease + + .errors .alert + padding: 5px + display: block + margin: 10px auto + .btn + margin-left: 10px .modal .wait diff --git a/app/templates/loading.jade b/app/templates/loading.jade index 519808a00..df2fc0eb5 100644 --- a/app/templates/loading.jade +++ b/app/templates/loading.jade @@ -1,4 +1,6 @@ .loading-screen h1(data-i18n="common.loading") Loading... - .progress.progress-striped.active - .progress-bar \ No newline at end of file + .progress + .progress-bar + + .errors \ No newline at end of file diff --git a/app/templates/loading_error.jade b/app/templates/loading_error.jade new file mode 100644 index 000000000..b56ede4ac --- /dev/null +++ b/app/templates/loading_error.jade @@ -0,0 +1,31 @@ +.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. + else + strong(data-i18n="loading_error.unknown") Unknown error. + + if resourceIndex !== undefined + button.btn.btn-sm.retry-loading-resource(data-i18n="common.retry", data-resource-index=resourceIndex) Retry + if requestIndex !== undefined + button.btn.btn-sm.retry-loading-request(data-i18n="common.retry", data-request-index=requestIndex) Retry + \ No newline at end of file diff --git a/app/views/kinds/CocoView.coffee b/app/views/kinds/CocoView.coffee index ed81e4a20..768072a26 100644 --- a/app/views/kinds/CocoView.coffee +++ b/app/views/kinds/CocoView.coffee @@ -2,6 +2,7 @@ SuperModel = require 'models/SuperModel' utils = require 'lib/utils' CocoClass = require 'lib/CocoClass' loadingScreenTemplate = require 'templates/loading' +loadingErrorTemplate = require 'templates/loading_error' visibleModal = null waitingModal = null @@ -18,13 +19,26 @@ module.exports = class CocoView extends Backbone.View 'click a': 'toggleModal' 'click button': 'toggleModal' 'click li': 'toggleModal' + 'click .retry-loading-resource': 'onRetryResource' + 'click .retry-loading-request': 'onRetryRequest' subscriptions: {} shortcuts: {} + # load progress properties + loadProgress: + num: 0 + denom: 0 + showing: false + resources: [] # models and collections + requests: [] # jqxhr's + somethings: [] # everything else + progress: 0 + # Setup, Teardown constructor: (options) -> + @loadProgress = _.cloneDeep @loadProgress @supermodel ?= options?.supermodel or new SuperModel() @options = options @subscriptions = utils.combineAncestralObject(@, 'subscriptions') @@ -33,6 +47,7 @@ module.exports = class CocoView extends Backbone.View @shortcuts = utils.combineAncestralObject(@, 'shortcuts') @subviews = {} @listenToShortcuts() + @updateProgressBar = _.debounce @updateProgressBar, 100 # Backbone.Mediator handles subscription setup/teardown automatically super options @@ -74,7 +89,7 @@ module.exports = class CocoView extends Backbone.View return @template if _.isString(@template) @$el.html @template(@getRenderData()) @afterRender() - @showLoading() if @startsLoading + @showLoading() if @startsLoading or @loading() # TODO: Remove startsLoading entirely @$el.i18n() @ @@ -89,6 +104,101 @@ module.exports = class CocoView extends Backbone.View context afterRender: -> + + # Resource and request loading management for any given view + + addResourceToLoad: (modelOrCollection, name, value=1) -> + @loadProgress.resources.push {resource:modelOrCollection, value:value, name:name} + @listenToOnce modelOrCollection, 'sync', @updateProgress + @listenTo modelOrCollection, 'error', @onResourceLoadFailed + @updateProgress() + + addRequestToLoad: (jqxhr, name, retryFunc, value=1) -> + @loadProgress.requests.push {request:jqxhr, value:value, name: name, retryFunc: retryFunc} + jqxhr.done @updateProgress + jqxhr.fail @onRequestLoadFailed + + addSomethingToLoad: (name, value=1) -> + @loadProgress.somethings.push {loaded: false, name: name, value: value} + @updateProgress() + + somethingLoaded: (name) -> + r = _.find @loadProgress.somethings, {name: name} + return console.error 'Could not find something called', name if not r + r.loaded = true + @updateProgress(name) + + loading: -> + return false if @loaded + for r in @loadProgress.resources + return true if not r.resource.loaded + for r in @loadProgress.requests + return true if not r.request.status + for r in @loadProgress.somethings + return true if not r.loaded + return false + + updateProgress: => + console.debug 'Loaded', r.name if arguments[0] and r = _.find @loadProgress.resources, {resource:arguments[0]} + console.debug 'Loaded', r.name if arguments[2] and r = _.find @loadProgress.requests, {request:arguments[2]} + console.debug 'Loaded', r.name if arguments[0] and r = _.find @loadProgress.somethings, {name:arguments[0]} + + denom = 0 + denom += r.value for r in @loadProgress.resources + denom += r.value for r in @loadProgress.requests + denom += r.value for r in @loadProgress.somethings + num = @loadProgress.num + num += r.value for r in @loadProgress.resources when r.resource.loaded + num += r.value for r in @loadProgress.requests when r.request.status + num += r.value for r in @loadProgress.somethings when r.loaded + #console.log 'update progress', @, num, denom, arguments + + progress = if denom then num / denom else 0 + # sometimes the denominator isn't known from the outset, so make sure the overall progress only goes up + @loadProgress.progress = progress if progress > @loadProgress.progress + @updateProgressBar() + if num is denom and not @loaded + @loaded = true + @onLoaded() + + updateProgressBar: => + prog = "#{parseInt(@loadProgress.progress*100)}%" + @$el.find('.loading-screen .progress-bar').css('width', prog) + + onLoaded: -> + @render() + + # Error handling for loading + + onResourceLoadFailed: (resource, jqxhr) -> + for r, index in @loadProgress.resources + break if r.resource is resource + @$el.find('.loading-screen .errors').append(loadingErrorTemplate({ + status:jqxhr.status, + name: r.name + resourceIndex: index, + responseText: jqxhr.responseText + })).i18n() + + onRetryResource: (e) -> + r = @loadProgress.resources[$(e.target).data('resource-index')] + r.resource.fetch() + $(e.target).closest('.loading-error-alert').remove() + + onRequestLoadFailed: (jqxhr) => + for r, index in @loadProgress.requests + break if r.request is jqxhr + @$el.find('.loading-screen .errors').append(loadingErrorTemplate({ + status:jqxhr.status, + name: r.name + requestIndex: index, + responseText: jqxhr.responseText + })) + + onRetryRequest: (e) -> + r = @loadProgress.requests[$(e.target).data('request-index')] + @[r.retryFunc]?() + $(e.target).closest('.loading-error-alert').remove() # Modals diff --git a/app/views/play/ladder/ladder_tab.coffee b/app/views/play/ladder/ladder_tab.coffee index db4eff8ad..ae8c92259 100644 --- a/app/views/play/ladder/ladder_tab.coffee +++ b/app/views/play/ladder/ladder_tab.coffee @@ -1,4 +1,5 @@ CocoView = require 'views/kinds/CocoView' +CocoClass = require 'lib/CocoClass' Level = require 'models/Level' LevelSession = require 'models/LevelSession' CocoCollection = require 'models/CocoCollection' @@ -18,7 +19,6 @@ class LevelSessionsCollection extends CocoCollection module.exports = class LadderTabView extends CocoView id: 'ladder-tab-view' template: require 'templates/play/ladder/ladder_tab' - startsLoading: true events: 'click .connect-facebook': 'onConnectFacebook' @@ -32,6 +32,7 @@ module.exports = class LadderTabView extends CocoView constructor: (options, @level, @sessions) -> super(options) + @addSomethingToLoad("social_network_apis") @teams = teamDataFromLevel @level @leaderboards = {} @refreshLadder() @@ -39,15 +40,16 @@ module.exports = class LadderTabView extends CocoView checkFriends: -> return if @checked or (not window.FB) or (not window.gapi) + @somethingLoaded("social_network_apis") @checked = true - - @loadingFacebookFriends = true + + @addSomethingToLoad("facebook_status") FB.getLoginStatus (response) => @facebookStatus = response.status - if @facebookStatus is 'connected' then @loadFacebookFriendSessions() else @loadingFacebookFriends = false + @somethingLoaded("facebook_status") + @loadFacebookFriends() if @facebookStatus is 'connected' if application.gplusHandler.loggedIn is undefined - @loadingGPlusFriends = true @listenToOnce(application.gplusHandler, 'checked-state', @gplusSessionStateLoaded) else @gplusSessionStateLoaded() @@ -60,16 +62,24 @@ module.exports = class LadderTabView extends CocoView onConnectedWithFacebook: -> location.reload() if @connecting + loadFacebookFriends: -> + @addSomethingToLoad("facebook_friends") + FB.api '/me/friends', @onFacebookFriendsLoaded + + onFacebookFriendsLoaded: (response) => + @somethingLoaded("facebook_friends") + @facebookData = response.data + @loadFacebookFriendSessions() + loadFacebookFriendSessions: -> - FB.api '/me/friends', (response) => - @facebookData = response.data - levelFrag = "#{@level.get('original')}.#{@level.get('version').major}" - url = "/db/level/#{levelFrag}/leaderboard_facebook_friends" - $.ajax url, { - data: { friendIDs: (f.id for f in @facebookData) } - method: 'POST' - success: @onFacebookFriendSessionsLoaded - } + levelFrag = "#{@level.get('original')}.#{@level.get('version').major}" + url = "/db/level/#{levelFrag}/leaderboard_facebook_friends" + jqxhr = $.ajax url, { + data: { friendIDs: (f.id for f in @facebookData) } + method: 'POST' + success: @onFacebookFriendSessionsLoaded + } + @addRequestToLoad(jqxhr, 'facebook_friend_sessions', 'loadFacebookFriendSessions') onFacebookFriendSessionsLoaded: (result) => friendsMap = {} @@ -79,9 +89,7 @@ module.exports = class LadderTabView extends CocoView friend.otherTeam = if friend.team is 'humans' then 'ogres' else 'humans' friend.imageSource = "http://graph.facebook.com/#{friend.facebookID}/picture" @facebookFriendSessions = result - @loadingFacebookFriends = false - @renderMaybe() - + # GOOGLE PLUS onConnectGPlus: -> @@ -93,21 +101,23 @@ module.exports = class LadderTabView extends CocoView gplusSessionStateLoaded: -> if application.gplusHandler.loggedIn - @loadingGPlusFriends = true + @addSomethingToLoad("gplus_friends") application.gplusHandler.loadFriends @gplusFriendsLoaded - else - @loadingGPlusFriends = false - @renderMaybe() gplusFriendsLoaded: (friends) => + @somethingLoaded("gplus_friends") @gplusData = friends.items + @loadGPlusFriendSessions() + + loadGPlusFriendSessions: -> levelFrag = "#{@level.get('original')}.#{@level.get('version').major}" url = "/db/level/#{levelFrag}/leaderboard_gplus_friends" - $.ajax url, { + jqxhr = $.ajax url, { data: { friendIDs: (f.id for f in @gplusData) } method: 'POST' success: @onGPlusFriendSessionsLoaded } + @addRequestToLoad(jqxhr, 'gplus_friend_sessions', 'loadGPlusFriendSessions') onGPlusFriendSessionsLoaded: (result) => friendsMap = {} @@ -117,29 +127,15 @@ module.exports = class LadderTabView extends CocoView friend.otherTeam = if friend.team is 'humans' then 'ogres' else 'humans' friend.imageSource = friendsMap[friend.gplusID].image.url @gplusFriendSessions = result - @loadingGPlusFriends = false - @renderMaybe() # LADDER LOADING refreshLadder: -> - promises = [] for team in @teams - @leaderboards[team.id]?.off 'sync' + @leaderboards[team.id]?.destroy() teamSession = _.find @sessions.models, (session) -> session.get('team') is team.id @leaderboards[team.id] = new LeaderboardData(@level, team.id, teamSession) - promises.push @leaderboards[team.id].promise - @loadingLeaderboards = true - $.when(promises...).then(@leaderboardsLoaded) - - leaderboardsLoaded: => - @loadingLeaderboards = false - @renderMaybe() - - renderMaybe: -> - return if @loadingFacebookFriends or @loadingLeaderboards or @loadingGPlusFriends - @startsLoading = false - @render() + @addResourceToLoad @leaderboards[team.id], 'leaderboard', 3 getRenderData: -> ctx = super() @@ -160,9 +156,16 @@ module.exports = class LadderTabView extends CocoView sessions.reverse() sessions -class LeaderboardData +class LeaderboardData extends CocoClass + ### + Consolidates what you need to load for a leaderboard into a single Backbone Model-like object. + ### + constructor: (@level, @team, @session) -> - _.extend @, Backbone.Events + super() + @fetch() + + fetch: -> @topPlayers = new LeaderboardCollection(@level, {order:-1, scoreOffset: HIGHEST_SCORE, team: @team, limit: 20}) promises = [] promises.push @topPlayers.fetch() @@ -173,18 +176,24 @@ class LeaderboardData promises.push @playersAbove.fetch() @playersBelow = new LeaderboardCollection(@level, {order:-1, scoreOffset: score, limit: 4, team: @team}) promises.push @playersBelow.fetch() - level = "#{level.get('original')}.#{level.get('version').major}" + level = "#{@level.get('original')}.#{@level.get('version').major}" success = (@myRank) => promises.push $.ajax "/db/level/#{level}/leaderboard_rank?scoreOffset=#{@session.get('totalScore')}&team=#{@team}", {success} @promise = $.when(promises...) @promise.then @onLoad + @promise.fail @onFail @promise onLoad: => + return if @destroyed @loaded = true - @trigger 'sync' + @trigger 'sync', @ # TODO: cache user ids -> names mapping, and load them here as needed, # and apply them to sessions. Fetching each and every time is too costly. + + onFail: (resource, jqxhr) => + return if @destroyed + @trigger 'error', @, jqxhr inTopSessions: -> return me.id in (session.attributes.creator for session in @topPlayers.models) @@ -201,3 +210,7 @@ class LeaderboardData startRank = @myRank - 4 session.rank = startRank + i for session, i in l l + + allResources: -> + resources = [@topPlayers, @playersAbove, @playersBelow] + return (r for r in resources when r) \ No newline at end of file diff --git a/app/views/play/ladder_view.coffee b/app/views/play/ladder_view.coffee index 6d1e90313..58366fa58 100644 --- a/app/views/play/ladder_view.coffee +++ b/app/views/play/ladder_view.coffee @@ -24,7 +24,6 @@ class LevelSessionsCollection extends CocoCollection module.exports = class LadderView extends RootView id: 'ladder-view' template: require 'templates/play/ladder' - startsLoading: true subscriptions: 'application:idle-changed': 'onIdleChanged' @@ -38,18 +37,18 @@ module.exports = class LadderView extends RootView constructor: (options, @levelID) -> super(options) @level = new Level(_id:@levelID) - p1 = @level.fetch() + @level.fetch() @sessions = new LevelSessionsCollection(levelID) - p2 = @sessions.fetch({}) + @sessions.fetch({}) + @addResourceToLoad(@sessions, 'your_sessions') + @addResourceToLoad(@level, 'level') @simulator = new Simulator() @listenTo(@simulator, 'statusUpdate', @updateSimulationStatus) @teams = [] - $.when(p1, p2).then @onLoaded - onLoaded: => + onLoaded: -> @teams = teamDataFromLevel @level - @startsLoading = false - @render() + super() getRenderData: -> ctx = super() @@ -63,7 +62,7 @@ module.exports = class LadderView extends RootView afterRender: -> super() - return if @startsLoading + return if @loading() @insertSubView(@ladderTab = new LadderTabView({}, @level, @sessions)) @insertSubView(@myMatchesTab = new MyMatchesTabView({}, @level, @sessions)) @refreshInterval = setInterval(@fetchSessionsAndRefreshViews.bind(@), 10 * 1000) @@ -72,7 +71,7 @@ module.exports = class LadderView extends RootView @showPlayModal(hash) if @sessions.loaded fetchSessionsAndRefreshViews: -> - return if @destroyed or application.userIsIdle or @$el.find('#simulate.active').length or (new Date() - 2000 < @lastRefreshTime) or @startsLoading + return if @destroyed or application.userIsIdle or @$el.find('#simulate.active').length or (new Date() - 2000 < @lastRefreshTime) or @loading() @sessions.fetch({"success": @refreshViews}) refreshViews: => diff --git a/app/views/play/level/tome/spell_view.coffee b/app/views/play/level/tome/spell_view.coffee index 17a813459..4951b6915 100644 --- a/app/views/play/level/tome/spell_view.coffee +++ b/app/views/play/level/tome/spell_view.coffee @@ -63,7 +63,7 @@ module.exports = class SpellView extends View @createFirepad() else # needs to happen after the code generating this view is complete - setTimeout @onLoaded, 1 + setTimeout @onAllLoaded, 1 createACE: -> # Test themes and settings here: http://ace.ajax.org/build/kitchen-sink.html @@ -178,9 +178,9 @@ module.exports = class SpellView extends View else @ace.setValue @previousSource @ace.clearSelection() - @onLoaded() + @onAllLoaded() - onLoaded: => + onAllLoaded: => @spell.transpile @spell.source @spell.loaded = true Backbone.Mediator.publish 'tome:spell-loaded', spell: @spell