codecombat/app/views/courses/CourseDetailsView.coffee
Nick Winter c77e1c0fa2 Add course complete visual state for student CourseDetailsView
Also including a few misc tweaks to CourseDetailsView and the end-of-course HeroVictoryModal state.
2015-12-02 09:52:52 -08:00

368 lines
15 KiB
CoffeeScript

Campaign = require 'models/Campaign'
CocoCollection = require 'collections/CocoCollection'
Course = require 'models/Course'
CourseInstance = require 'models/CourseInstance'
Classroom = require 'models/Classroom'
LevelSession = require 'models/LevelSession'
RootView = require 'views/core/RootView'
template = require 'templates/courses/course-details'
User = require 'models/User'
utils = require 'core/utils'
Prepaid = require 'models/Prepaid'
autoplayedOnce = false
module.exports = class CourseDetailsView extends RootView
id: 'course-details-view'
template: template
teacherMode: false
singlePlayerMode: false
memberSort: 'nameAsc'
events:
'change .progress-expand-checkbox': 'onCheckExpandedProgress'
'click .btn-play-level': 'onClickPlayLevel'
'click .btn-select-instance': 'onClickSelectInstance'
'click .progress-member-header': 'onClickMemberHeader'
'click .progress-header': 'onClickProgressHeader'
'click .progress-level-cell': 'onClickProgressLevelCell'
'mouseenter .progress-level-cell': 'onMouseEnterPoint'
'mouseleave .progress-level-cell': 'onMouseLeavePoint'
constructor: (options, @courseID, @courseInstanceID) ->
super options
@courseID ?= options.courseID
@courseInstanceID ?= options.courseInstanceID
@classroom = new Classroom()
@course = @supermodel.getModel(Course, @courseID) or new Course _id: @courseID
@listenTo @course, 'sync', @onCourseSync
@prepaid = new Prepaid()
if @course.loaded
@onCourseSync()
else
@supermodel.loadModel @course, 'course'
getRenderData: ->
context = super()
context.campaign = @campaign
context.conceptsCompleted = @conceptsCompleted ? {}
context.course = @course if @course?.loaded
context.courseInstance = @courseInstance if @courseInstance?.loaded
context.courseInstances = @courseInstances?.models ? []
context.instanceStats = @instanceStats
context.levelConceptMap = @levelConceptMap ? {}
context.memberSort = @memberSort
context.memberStats = @memberStats
context.memberUserMap = @memberUserMap ? {}
context.noCourseInstance = @noCourseInstance
context.noCourseInstanceSelected = @noCourseInstanceSelected
context.pricePerSeat = @course.get('pricePerSeat')
context.showExpandedProgress = @showExpandedProgress
context.sortedMembers = @sortedMembers ? []
context.userConceptStateMap = @userConceptStateMap ? {}
context.userLevelStateMap = @userLevelStateMap ? {}
context.document = document
context
afterRender: ->
super()
if @supermodel.finished() and @courseComplete and me.isAnonymous() and @options.justBeatLevel
# TODO: Make an intermediate modal that tells them they've finished HoC and has some snazzy stuff for convincing players to sign up instead of just throwing up the bare AuthModal
AuthModal = require 'views/core/AuthModal'
@openModalView new AuthModal showSignupRationale: true
onCourseSync: ->
return if @destroyed
# console.log 'onCourseSync'
if me.isAnonymous() and (not me.get('hourOfCode') and not @course.get('hourOfCode'))
@noCourseInstance = true
@render()
return
return if @campaign?
campaignID = @course.get('campaignID')
@campaign = @supermodel.getModel(Campaign, campaignID) or new Campaign _id: campaignID
@listenTo @campaign, 'sync', @onCampaignSync
if @campaign.loaded
@onCampaignSync()
else
@supermodel.loadModel @campaign, 'campaign'
@render()
onCampaignSync: ->
return if @destroyed
# console.log 'onCampaignSync'
if @courseInstanceID
@loadCourseInstance(@courseInstanceID)
else unless me.isAnonymous()
@loadCourseInstances()
@levelConceptMap = {}
for levelID, level of @campaign.get('levels')
@levelConceptMap[levelID] ?= {}
for concept in level.concepts
@levelConceptMap[levelID][concept] = true
if level.type is 'course-ladder'
@arenaLevel = level
@render()
loadCourseInstances: ->
@courseInstances = new CocoCollection [], {url: "/db/user/#{me.id}/course_instances", model: CourseInstance, comparator: 'courseID'}
@listenToOnce @courseInstances, 'sync', @onCourseInstancesSync
@supermodel.loadCollection @courseInstances, 'course_instances'
loadAllCourses: ->
@allCourses = new CocoCollection [], {url: "/db/course", model: Course, comparator: '_id'}
@listenToOnce @allCourses, 'sync', @onAllCoursesSync
@supermodel.loadCollection @allCourses, 'courses'
loadCourseInstance: (courseInstanceID) ->
return if @destroyed
# console.log 'loadCourseInstance'
return if @courseInstance?
@courseInstanceID = courseInstanceID
@courseInstance = @supermodel.getModel(CourseInstance, @courseInstanceID) or new CourseInstance _id: @courseInstanceID
@listenTo @courseInstance, 'sync', @onCourseInstanceSync
if @courseInstance.loaded
@onCourseInstanceSync()
else
@courseInstance = @supermodel.loadModel(@courseInstance, 'course_instance').model
onCourseInstancesSync: ->
return if @destroyed
# console.log 'onCourseInstancesSync'
@findNextCourseInstance()
if not @courseInstance
# We are loading these to find the one we want to display.
if @courseInstances.models.length is 1
@loadCourseInstance(@courseInstances.models[0].id)
else
if @courseInstances.models.length is 0
@noCourseInstance = true
else
@noCourseInstanceSelected = true
@render()
onCourseInstanceSync: ->
return if @destroyed
# console.log 'onCourseInstanceSync'
if @courseInstance.get('classroomID')
@classroom = new Classroom({_id: @courseInstance.get('classroomID')})
@supermodel.loadModel @classroom, 'classroom'
@singlePlayerMode = @courseInstance.get('name') is 'Single Player'
@teacherMode = @courseInstance.get('ownerID') is me.id and not @singlePlayerMode
@levelSessions = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/level_sessions", model: LevelSession, comparator: '_id' })
@listenToOnce @levelSessions, 'sync', @onLevelSessionsSync
@supermodel.loadCollection @levelSessions, 'level_sessions', cache: false
@members = new CocoCollection([], { url: "/db/course_instance/#{@courseInstance.id}/members", model: User, comparator: 'nameLower' })
@listenToOnce @members, 'sync', @onMembersSync
@supermodel.loadCollection @members, 'members', cache: false
@owner = new User({_id: @courseInstance.get('ownerID')})
@supermodel.loadModel @owner, 'user'
if @teacherMode and prepaidID = @courseInstance.get('prepaidID')
@prepaid = @supermodel.getModel(Prepaid, prepaidID) or new Prepaid _id: prepaidID
@listenTo @prepaid, 'sync', @onPrepaidSync
if @prepaid.loaded
@onPrepaidSync()
else
@supermodel.loadModel @prepaid, 'prepaid'
@render()
onPrepaidSync: ->
return if @destroyed
# TODO: why do we rerender here? Template doesn't use prepaid.
@render()
onLevelSessionsSync: ->
return if @destroyed
# console.log 'onLevelSessionsSync'
@instanceStats = averageLevelsCompleted: 0, furthestLevelCompleted: '', totalLevelsCompleted: 0, totalPlayTime: 0
@memberStats = {}
@userConceptStateMap = {}
@userLevelSessionMap = {}
@userLevelStateMap = {}
levelStateMap = {}
for levelSession in @levelSessions.models
continue if levelSession.skipMe # Don't track second arena session as another completed level
userID = levelSession.get('creator')
levelID = levelSession.get('level').original
state = if levelSession.get('state')?.complete then 'complete' else 'started'
playtime = parseInt(levelSession.get('playtime') ? 0, 10)
do (userID, levelID) =>
secondSessionForLevel = _.find(@levelSessions.models, ((otherSession) ->
otherSession.get('creator') is userID and otherSession.get('level').original is levelID and otherSession.id isnt levelSession.id
))
if secondSessionForLevel
state = 'complete' if secondSessionForLevel.get('state')?.complete
playtime = playtime + parseInt(secondSessionForLevel.get('playtime') ? 0, 10)
secondSessionForLevel.skipMe = true
levelStateMap[levelID] = state
@instanceStats.totalLevelsCompleted++ if state is 'complete'
@instanceStats.totalPlayTime += playtime
@memberStats[userID] ?= totalLevelsCompleted: 0, totalPlayTime: 0
@memberStats[userID].totalLevelsCompleted++ if state is 'complete'
@memberStats[userID].totalPlayTime += playtime
@userConceptStateMap[userID] ?= {}
for concept of @levelConceptMap[levelID]
@userConceptStateMap[userID][concept] = state
@userLevelSessionMap[userID] ?= {}
@userLevelSessionMap[userID][levelID] = levelSession
@userLevelStateMap[userID] ?= {}
@userLevelStateMap[userID][levelID] = state
if @courseInstance.get('members').length > 0
@instanceStats.averageLevelsCompleted = @instanceStats.totalLevelsCompleted / @courseInstance.get('members').length
@instanceStats.averageLevelPlaytime = @instanceStats.totalPlayTime / @courseInstance.get('members').length
for levelID, level of @campaign.get('levels')
@instanceStats.furthestLevelCompleted = level.name if levelStateMap[levelID] is 'complete'
@conceptsCompleted = {}
for userID, conceptStateMap of @userConceptStateMap
for concept, state of conceptStateMap
@conceptsCompleted[concept] ?= 0
@conceptsCompleted[concept]++
if @memberStats[me.id]?.totalLevelsCompleted >= _.size(@campaign.get('levels')) - 1 # Don't need to complete arena
@courseComplete = true
@loadCourseInstances() unless @courseInstances # Find the next course instance to do.
@render()
# If we just joined a single-player course for Hour of Code, we automatically play.
if @instanceStats.totalLevelsCompleted is 0 and @instanceStats.totalPlayTime is 0 and @singlePlayerMode and not autoplayedOnce
autoplayedOnce = true
@$el.find('button.btn-play-level').click()
onMembersSync: ->
return if @destroyed
# console.log 'onMembersSync'
@memberUserMap = {}
for user in @members.models
@memberUserMap[user.id] = user
@sortMembers()
@render()
onAllCoursesSync: ->
@findNextCourseInstance()
findNextCourseInstance: ->
@nextCourseInstance = _.find @courseInstances.models, (ci) =>
# Sorted by courseID
ci.get('classroomID') is @courseInstance.get('classroomID') and ci.id isnt @courseInstance.id and ci.get('courseID') > @course.id
if @nextCourseInstance
nextCourseID = @nextCourseInstance.get('courseID')
@nextCourse = @supermodel.getModel(Course, nextCourseID) or new Course _id: nextCourseID
@nextCourse = @supermodel.loadModel(@nextCourse, 'course').model
else if @allCourses?.loaded
@nextCourse = _.find @allCourses.models, (course) => course.id > @course.id
else
@loadAllCourses()
onCheckExpandedProgress: (e) ->
@showExpandedProgress = $('.progress-expand-checkbox').prop('checked')
# TODO: why does render reset the checkbox to be unchecked?
@render()
$('.progress-expand-checkbox').attr('checked', @showExpandedProgress)
onClickMemberHeader: (e) ->
@memberSort = if @memberSort is 'nameAsc' then 'nameDesc' else 'nameAsc'
@sortMembers()
@render()
onClickProgressHeader: (e) ->
@memberSort = if @memberSort is 'progressAsc' then 'progressDesc' else 'progressAsc'
@sortMembers()
@render()
onClickPlayLevel: (e) ->
levelSlug = $(e.target).data('level-slug')
levelID = $(e.target).data('level-id')
level = @campaign.get('levels')[levelID]
if level.type is 'course-ladder'
route = '/play/ladder/' + levelSlug
route += '/course/' + @courseInstance.id unless @singlePlayerMode # No league for solo courses
Backbone.Mediator.publish 'router:navigate', route: route
else
Backbone.Mediator.publish 'router:navigate', {
route: @getLevelURL levelSlug
viewClass: 'views/play/level/PlayLevelView'
viewArgs: [{courseID: @courseID, courseInstanceID: @courseInstanceID}, levelSlug]
}
getLevelURL: (levelSlug) ->
"/play/level/#{levelSlug}?course=#{@courseID}&course-instance=#{@courseInstanceID}"
onClickSelectInstance: (e) ->
courseInstanceID = $('.select-instance').val()
@noCourseInstanceSelected = false
@loadCourseInstance(courseInstanceID)
onClickProgressLevelCell: (e) ->
return unless @teacherMode or me.isAdmin()
levelID = $(e.currentTarget).data('level-id')
levelSlug = $(e.currentTarget).data('level-slug')
userID = $(e.currentTarget).data('user-id')
return unless levelID and levelSlug and userID
route = @getLevelURL levelSlug
if @userLevelSessionMap[userID]?[levelID]
route += "&session=#{@userLevelSessionMap[userID][levelID].id}&observing=true"
Backbone.Mediator.publish 'router:navigate', {
route: route
viewClass: 'views/play/level/PlayLevelView'
viewArgs: [{}, levelSlug]
}
onMouseEnterPoint: (e) ->
$('.progress-popup-container').hide()
container = $(e.target).find('.progress-popup-container').show()
margin = 20
offset = $(e.target).offset()
scrollTop = $('#page-container').scrollTop()
height = container.outerHeight()
container.css('left', offset.left + e.offsetX)
container.css('top', offset.top + scrollTop - height - margin)
onMouseLeavePoint: (e) ->
$(e.target).find('.progress-popup-container').hide()
sortMembers: ->
# Progress sort precedence: most completed concepts, most started concepts, most levels, name sort
return unless @campaign and @courseInstance and @memberUserMap
@sortedMembers = @courseInstance.get('members')
switch @memberSort
when "nameDesc"
@sortedMembers.sort (a, b) =>
aName = @memberUserMap[a]?.get('name') ? 'Anoner'
bName = @memberUserMap[b]?.get('name') ? 'Anoner'
bName.localeCompare(aName)
when "progressAsc"
@sortedMembers.sort (a, b) =>
for levelID, level of @campaign.get('levels')
if @userLevelStateMap[a]?[levelID] isnt 'complete' and @userLevelStateMap[b]?[levelID] is 'complete'
return -1
else if @userLevelStateMap[a]?[levelID] is 'complete' and @userLevelStateMap[b]?[levelID] isnt 'complete'
return 1
0
when "progressDesc"
@sortedMembers.sort (a, b) =>
for levelID, level of @campaign.get('levels')
if @userLevelStateMap[a]?[levelID] isnt 'complete' and @userLevelStateMap[b]?[levelID] is 'complete'
return 1
else if @userLevelStateMap[a]?[levelID] is 'complete' and @userLevelStateMap[b]?[levelID] isnt 'complete'
return -1
0
else
@sortedMembers.sort (a, b) =>
aName = @memberUserMap[a]?.get('name') ? 'Anoner'
bName = @memberUserMap[b]?.get('name') ? 'Anoner'
aName.localeCompare(bName)
getOwnerName: ->
return if @owner.isNew()
if @owner.get('firstName') and @owner.get('lastName')
return "#{@owner.get('firstName')} #{@owner.get('lastName')}"
@owner.get('name') or @owner.get('email')