Refactor and update teacher-dashboard

This updates TeacherClassView and ActivateLicensesModal to use the
new state-based rendering system, making it much snappier and less clunky
feeling, and improving data consistency.

Features also included in this:
- Hover details for progress dots in TeacherClassView
- ActivateLicensesModal has an "All Students" option and better handling
  when you switch classrooms in the dropdown
- Unenrolled/Unassigned students are shown separately in Course Progress and
  can be enrolled/assigned from there.

Add Back to Classes button on demo-request submitted view

Delete temporary patch file

Show unenrolled students separately in Course Progress (incomplete)

Migrate TeacherClassView to use orchestrator-style events, add unassigned students section, replace bootstrap tabs with state-based tabs

Convert missed instance variables to be in @state

Fix merge errors

(in progress) Convert a bunch of stuff to use state and events (removing student needs fixing)

Fix up modal interactions, some bugs

Switch state to be a Model, sync up course dropdowns

Convert student sorting to use state model

Add hover tooltips to TeacherClassView Students tab

Don't keep tooltip open when you mouse into it

Add dateFirstCompleted and Course Progress tooltips

Course Overview progress tooltips

Refactor ActivateLicensesModal

Refactors:
Uses state object for view state
Passes back the updated users in 'redeem-users' event instead of modifying given collection

Features:
Add 'All Students' dropdown option
Don't forget checked students if you change classroom from dropdown,
  but only enroll the ones visible when you click "Enroll (n) Students"

Separate enrolled students; improve style

Rearrange error text

Disable enroll-students button when none are selected

Remove console.logs

Move style-flat variables to another file

This prevents .style-flat from being copied in multiple times to the resulting CSS.

Show Unarchive button when on the page for an archived class

Move text to en.coffee

Only sort students on first classroom sync

Fix merge error

Handle sessions missing completion date in view logic instead of migration script

Listen to classroom sync more than once in case it gets unarchived
This commit is contained in:
phoenixeliot 2016-04-19 13:44:48 -07:00
parent 0ed99565d3
commit 8223122a6b
26 changed files with 564 additions and 227 deletions

View file

@ -139,12 +139,17 @@ module.exports =
levels = classroom.getLevels({courseID: course.id, withoutLadderLevels: true}) levels = classroom.getLevels({courseID: course.id, withoutLadderLevels: true})
for level in levels.models for level in levels.models
levelID = level.get('original') levelID = level.get('original')
progressData[classroom.id][course.id][levelID] = { completed: students.size() > 0, started: false } progressData[classroom.id][course.id][levelID] = {
completed: students.size() > 0,
started: false
numStarted: 0
# numCompleted: 0
}
for user in students.models for user in students.models
userID = user.id userID = user.id
courseProgress = progressData[classroom.id][course.id] courseProgress = progressData[classroom.id][course.id]
courseProgress[userID] ?= { completed: true, started: false } # Only set it the first time through a user courseProgress[userID] ?= { completed: true, started: false, levelsCompleted: 0 } # Only set it the first time through a user
courseProgress[levelID][userID] = { completed: true, started: false } # These don't matter, will always be set courseProgress[levelID][userID] = { completed: true, started: false } # These don't matter, will always be set
session = _.find classroom.sessions.models, (session) -> session = _.find classroom.sessions.models, (session) ->
session.get('creator') is userID and session.get('level').original is levelID session.get('creator') is userID and session.get('level').original is levelID
@ -158,21 +163,30 @@ module.exports =
courseProgress[levelID].completed = false courseProgress[levelID].completed = false
courseProgress[levelID][userID].started = false courseProgress[levelID][userID].started = false
courseProgress[levelID][userID].completed = false courseProgress[levelID][userID].completed = false
if session # have gotten to the level and at least started it if session # have gotten to the level and at least started it
courseProgress.started = true courseProgress.started = true
courseProgress[userID].started = true courseProgress[userID].started = true
courseProgress[levelID].started = true courseProgress[levelID].started = true
courseProgress[levelID][userID].started = true courseProgress[levelID][userID].started = true
courseProgress[levelID][userID].lastPlayed = new Date(session.get('changed'))
courseProgress[levelID].numStarted += 1
if session?.completed() # have finished this level if session?.completed() # have finished this level
courseProgress.completed &&= true #no-op courseProgress.completed &&= true #no-op
courseProgress[userID].completed = true courseProgress[userID].completed &&= true #no-op
courseProgress[userID].levelsCompleted += 1
courseProgress[levelID].completed &&= true #no-op courseProgress[levelID].completed &&= true #no-op
# courseProgress[levelID].numCompleted += 1
courseProgress[levelID][userID].completed = true courseProgress[levelID][userID].completed = true
courseProgress[levelID][userID].dateFirstCompleted = new Date(session.get('dateFirstCompleted') || session.get('changed'))
else # level started but not completed else # level started but not completed
courseProgress.completed = false courseProgress.completed = false
courseProgress[userID].completed = false courseProgress[userID].completed = false
courseProgress[levelID].completed = false courseProgress[levelID].completed = false
courseProgress[levelID][userID].completed = false courseProgress[levelID][userID].completed = false
courseProgress[levelID].dateFirstCompleted = null
courseProgress[levelID][userID].dateFirstCompleted = null
_.assign(progressData, progressMixin) _.assign(progressData, progressMixin)
return progressData return progressData

View file

@ -831,6 +831,7 @@
thanks_header: "Request Received!" thanks_header: "Request Received!"
thanks_sub_header: "Thanks for expressing interest in CodeCombat for your school." thanks_sub_header: "Thanks for expressing interest in CodeCombat for your school."
thanks_p: "We'll be in touch soon! If you need to get in contact, you can reach us at:" thanks_p: "We'll be in touch soon! If you need to get in contact, you can reach us at:"
back_to_classes: "Back to Classes"
finish_signup: "Finish creating your teacher account:" finish_signup: "Finish creating your teacher account:"
finish_signup_p: "Create an account to set up a class, add your students, and monitor their progress as they learn computer science." finish_signup_p: "Create an account to set up a class, add your students, and monitor their progress as they learn computer science."
signup_with: "Sign up with:" signup_with: "Sign up with:"
@ -1291,6 +1292,7 @@
view_class: "view class" view_class: "view class"
archive_class: "archive class" archive_class: "archive class"
unarchive_class: "unarchive class" unarchive_class: "unarchive class"
unarchive_this_class: "Unarchive this class"
no_students_yet: "This class has no students yet." no_students_yet: "This class has no students yet."
add_students: "Add Students" add_students: "Add Students"
create_new_class: "Create a New Class" create_new_class: "Create a New Class"
@ -1312,6 +1314,10 @@
latest_completed: "Latest Completed" latest_completed: "Latest Completed"
sort_by: "Sort by" sort_by: "Sort by"
progress: "Progress" progress: "Progress"
completed: "Completed"
started: "Started"
click_to_view_progress: "click to view progress"
no_progress: "No progress"
select_course: "Select course to view" select_course: "Select course to view"
course_overview: "Course Overview" course_overview: "Course Overview"
copy_class_code: "Copy Class Code" copy_class_code: "Copy Class Code"

View file

@ -21,7 +21,7 @@ module.exports = class CourseInstance extends CocoModel
data: { userID: userID } data: { userID: userID }
} }
_.extend options, opts _.extend options, opts
@fetch(options) @fetch options
if userID is me.id if userID is me.id
unless me.get('courseInstances') unless me.get('courseInstances')
me.set('courseInstances', []) me.set('courseInstances', [])
@ -32,6 +32,8 @@ module.exports = class CourseInstance extends CocoModel
method: 'POST' method: 'POST'
url: _.result(@, 'url') + '/members' url: _.result(@, 'url') + '/members'
data: { userIDs } data: { userIDs }
success: =>
@trigger 'add-members', { userIDs }
} }
_.extend options, opts _.extend options, opts
@fetch(options) @fetch(options)

5
app/models/State.coffee Normal file
View file

@ -0,0 +1,5 @@
CocoModel = require './CocoModel'
schema = require 'schemas/models/poll.schema'
module.exports = class State extends CocoModel
@className: 'State'

View file

@ -55,6 +55,10 @@ _.extend LevelSessionSchema.properties,
title: 'Changed' title: 'Changed'
readOnly: true readOnly: true
dateFirstCompleted: c.stringDate
title: 'Completed'
readOnly: true
team: c.shortString() team: c.shortString()
level: LevelSessionLevelSchema level: LevelSessionLevelSchema

View file

@ -1,6 +1,6 @@
@import "app/styles/bootstrap/variables" @import "app/styles/bootstrap/variables"
@import "app/styles/mixins" @import "app/styles/mixins"
@import "app/styles/style-flat" @import "app/styles/style-flat-variables"
#about-view #about-view

View file

@ -1,4 +1,13 @@
#activate-licenses-modal #activate-licenses-modal
select
min-width: 80%
.checkbox
margin: 0
input[type='checkbox']
margin-top: 8px
.modal-content .modal-content
padding: 60px padding: 60px
width: 690px width: 690px

View file

@ -1,6 +1,9 @@
@import "app/styles/bootstrap/variables" @import "app/styles/bootstrap/variables"
@import "app/styles/mixins" @import "app/styles/mixins"
@import "app/styles/style-flat" @import "app/styles/style-flat-variables"
.nowrap
white-space: nowrap
.alternating-background:nth-child(2n+1) .alternating-background:nth-child(2n+1)
background-color: #ebebeb background-color: #ebebeb
@ -188,6 +191,26 @@
.progress-dot .progress-dot
margin: 5px margin: 5px
.unassigned-students
margin-top: 75px
line-height: 45px
.student-name, .student-email, .latest-completed
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
.small-details, .small
line-height: 45px
.latest-completed
white-space: nowrap
.level-name
display: inline
.btn
margin-top: 6.5px
margin-bottom: 6.5px
// Checkboxes // Checkboxes
.checkbox-flat .checkbox-flat
margin: 8px auto margin: 8px auto

View file

@ -1,6 +1,6 @@
@import "app/styles/bootstrap/variables" @import "app/styles/bootstrap/variables"
@import "app/styles/mixins" @import "app/styles/mixins"
@import "app/styles/style-flat" @import "app/styles/style-flat-variables"
#teacher-classes-view #teacher-classes-view

View file

@ -1,6 +1,6 @@
@import "app/styles/bootstrap/variables" @import "app/styles/bootstrap/variables"
@import "app/styles/mixins" @import "app/styles/mixins"
@import "app/styles/style-flat" @import "app/styles/style-flat-variables"
#teacher-dashboard-nav #teacher-dashboard-nav
vertical-align: middle vertical-align: middle

View file

@ -1,6 +1,6 @@
@import "app/styles/bootstrap/variables" @import "app/styles/bootstrap/variables"
@import "app/styles/mixins" @import "app/styles/mixins"
@import "app/styles/style-flat" @import "app/styles/style-flat-variables"
#new-home-view #new-home-view

View file

@ -0,0 +1,7 @@
$headline-font: 'Arvo', serif
$body-font: 'Open Sans', sans-serif
$burgandy: #7D0101
$gold: #F2BE19
$navy: #0E4C60
$forest: #20572B

View file

@ -1,13 +1,9 @@
@import "app/styles/bootstrap/variables" @import "app/styles/bootstrap/variables"
@import "app/styles/mixins" @import "app/styles/mixins"
@import "app/styles/style-flat-variables"
// TODO: Move flat style into probably several files and Bootstrap variables // TODO: Move flat style into probably several files and Bootstrap variables
// Variables
$headline-font: 'Arvo', serif
$body-font: 'Open Sans', sans-serif
body[lang='ru'], body[lang='uk'], body[lang='bg'], body[lang^='mk'], body[lang='sr'] body[lang='ru'], body[lang='uk'], body[lang='bg'], body[lang^='mk'], body[lang='sr']
// Google Fonts version of Arvo only has Latin glyphs, not Cyrillic // Google Fonts version of Arvo only has Latin glyphs, not Cyrillic
// TODO: figure out font fallbacks for other languages not covered by Arvo // TODO: figure out font fallbacks for other languages not covered by Arvo
@ -15,11 +11,6 @@ body[lang='ru'], body[lang='uk'], body[lang='bg'], body[lang^='mk'], body[lang='
h1, .text-h1, h3, .text-h3, h5, .text-h5 h1, .text-h1, h3, .text-h3, h5, .text-h5
font-family: 'Open Sans', serif font-family: 'Open Sans', serif
$burgandy: #7D0101
$gold: #F2BE19
$navy: #0E4C60
$forest: #20572B
.style-flat .style-flat
background: white background: white
color: black color: black
@ -194,6 +185,15 @@ $forest: #20572B
color: $gold color: $gold
text-shadow: 1px 1px black, -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black text-shadow: 1px 1px black, -1px -1px 0 black, 1px -1px 0 black, -1px 1px 0 black
// Wells
.well
padding: 8px
background-color: transparent
border: thin solid lightgray
border-radius: 0
// Buttons // Buttons
.btn .btn
@ -272,6 +272,8 @@ $forest: #20572B
// TODO: Font size 18? Inconsistent with buttons on teacher-class-view bulk assign // TODO: Font size 18? Inconsistent with buttons on teacher-class-view bulk assign
// Tooltips // Tooltips
.tooltip.in
opacity: 1
.tooltip .tooltip-arrow::after .tooltip .tooltip-arrow::after
// Create a duplicate tooltip arrow which will cover the main arrow and make it seem like a line rather than filled // Create a duplicate tooltip arrow which will cover the main arrow and make it seem like a line rather than filled
@ -334,6 +336,7 @@ $forest: #20572B
background: white background: white
border-radius: 20px border-radius: 20px
min-width: 150px min-width: 150px
max-width: 600px
// Checkboxes // Checkboxes

View file

@ -5,49 +5,67 @@ block modal-header-content
.text-center .text-center
h1(data-i18n="teacher.enroll_students") h1(data-i18n="teacher.enroll_students")
h2(data-i18n="courses.grants_lifetime_access") h2(data-i18n="courses.grants_lifetime_access")
if view.classroom
p= view.classroom.get('name')
block modal-body-content block modal-body-content
- var numToEnroll = state.get('visibleSelectedUsers').length
- var unusedEnrollments = view.prepaids.totalMaxRedeemers() - view.prepaids.totalRedeemers()
- var tooManySelected = numToEnroll > unusedEnrollments
- var noneSelected = numToEnroll == 0
if view.classrooms.length > 1 if view.classrooms.length > 1
.text-center .row
span(data-i18n='teacher.show_students_from') .col-sm-10.col-sm-offset-1
span.spr : .text-center.m-b-3
select .small.color-navy
each classroom in view.classrooms.models span(data-i18n='teacher.show_students_from')
option(selected=(classroom.id === view.classroom.id), value=classroom.id) span.spr :
= classroom.get('name') select.classroom-select
//- option(selected=!view.classroom, value='all-classrooms' data-i18n='teacher.all_students') each classroom in view.classrooms.models
form.form option(selected=(classroom.id === view.classroom.id), value=classroom.id)
= classroom.get('name')
option(selected=(view.classroom.id === 'all-students'), value='all-students' data-i18n='teacher.all_students')
form.form.m-t-3
span(data-i18n="teacher.enroll_the_following_students") span(data-i18n="teacher.enroll_the_following_students")
span : span :
.well.form-group .well.form-group
for user in view.users.models - var enrolledUsers = view.users.filter(function(user){ return Boolean(user.get('coursePrepaidID')) })
- var unenrolledUsers = view.users.filter(function(user){ return !Boolean(user.get('coursePrepaidID')) })
for user in unenrolledUsers
- var selected = Boolean(paid || state.get('selectedUsers').get(user.id))
.checkbox .checkbox
label label
- var paid = user.get('coursePrepaidID') input.user-checkbox(type="checkbox", disabled=false, checked=selected, data-user-id=user.id, name='user')
- var selected = (view.selectedUsers.get(user.id) ? true : false) span.spr= user.broadName()
input(type="checkbox", disabled=paid, checked=selected, data-user-id=user.id, name='user') if enrolledUsers.length > 0
.small-details.m-t-3
span(data-i18n='TODO')
| The following students are already enrolled:
for user in enrolledUsers
- var selected = Boolean(paid || state.get('selectedUsers').get(user.id))
.checkbox
label
input.user-checkbox(type="checkbox", disabled=true, checked=true, data-user-id=user.id, name='user')
span.spr= user.broadName() span.spr= user.broadName()
if paid
span (
span(data-i18n="courses.already_enrolled")
span )
#error-alert.alert.alert-danger.hide if state.get('error')
.alert.alert-danger
= state.get('error')
#submit-form-area.text-center #submit-form-area.text-center
p.small-details.not-enough-enrollments(class=(tooManySelected ? 'visible' : ''))
span(data-i18n='teacher.not_enough_enrollments')
p.small-details p.small-details
span.spr(data-i18n="courses.enrollment_credits_available") span.spr(data-i18n="courses.enrollment_credits_available")
span#total-available= view.prepaids.totalAvailable() span#total-available= view.prepaids.totalAvailable()
p.small-details.not-enough-enrollments
span(data-i18n='teacher.not_enough_enrollments')
p p
button#activate-licenses-btn.btn.btn-lg.btn-primary(type="submit") button#activate-licenses-btn.btn.btn-lg.btn-primary(type="submit" class=(tooManySelected || noneSelected ? 'disabled' : ''))
span.spr(data-i18n="courses.enroll") span.spr(data-i18n="courses.enroll")
| ( | (
span#total-selected-span span#total-selected-span
= numToEnroll
| ) | )
span.spl(data-i18n="courses.students1") span.spl(data-i18n="courses.students1")

View file

@ -18,6 +18,11 @@ block content
if classroom.loaded if classroom.loaded
.container .container
+breadcrumbs +breadcrumbs
if classroom.get('archived')
.row.center-block.text-center.m-t-3.m-b-3
.unarchive-btn.btn.btn-lg.btn-navy
span(data-i18n='teacher.unarchive_this_class')
h3.m-t-2= classroom.get('name') h3.m-t-2= classroom.get('name')
a.label.edit-classroom(data-classroom-id=classroom.id) a.label.edit-classroom(data-classroom-id=classroom.id)
span(data-i18n='teacher.edit_class_settings') span(data-i18n='teacher.edit_class_settings')
@ -25,7 +30,7 @@ block content
.classroom-info-row.row.m-t-5 .classroom-info-row.row.m-t-5
.classroom-details.col-md-3 .classroom-details.col-md-3
- var stats = view.classStats() - var stats = state.get('classStats')
h4.m-b-2(data-i18n='teacher.class_overview') h4.m-b-2(data-i18n='teacher.class_overview')
.language.small-details .language.small-details
@ -68,33 +73,33 @@ block content
span(data-i18n='teacher.export_student_progress') span(data-i18n='teacher.export_student_progress')
//- .concepts.small-details //- .concepts.small-details
//- if view.progressData //- if state.get('progressData')
//- div //- div
//- span(data-i18n='teacher.concepts_covered') //- span(data-i18n='teacher.concepts_covered')
//- span : //- span :
//- - console.log('concepts', view.conceptData) //- - console.log('concepts', view.conceptData)
//- - concepts = view.conceptData //- - concepts = view.conceptData
//- each state, name in view.conceptData[view.classroom.id] //- each state, name in view.conceptData[view.classroom.id]
//- if state.started //- if state.get('started')
//- b.concept(class=state.completed ? 'forest' : 'gold') //- b.concept(class=state.get('completed') ? 'forest' : 'gold')
//- span(data-i18n='concepts.'+name) //- span(data-i18n='concepts.'+name)
.completeness-info.col-md-4 .completeness-info.col-md-4
h4.m-b-2 h4.m-b-2
   
if view.earliestIncompleteLevel if state.get('earliestIncompleteLevel')
div.small-details div.small-details
span(data-i18n='teacher.earliest_incomplete') span(data-i18n='teacher.earliest_incomplete')
span : span :
+longLevelName(view.earliestIncompleteLevel) +longLevelName(state.get('earliestIncompleteLevel'))
+inlineUserList(view.earliestIncompleteLevel.users) +inlineUserList(state.get('earliestIncompleteLevel').users)
if view.latestCompleteLevel if state.get('latestCompleteLevel')
div.small-details.m-t-3 div.small-details.m-t-3
span(data-i18n='teacher.latest_complete') span(data-i18n='teacher.latest_complete')
span : span :
+longLevelName(view.latestCompleteLevel) +longLevelName(state.get('latestCompleteLevel'))
+inlineUserList(view.latestCompleteLevel.users) +inlineUserList(state.get('latestCompleteLevel').users)
.adding-students.col-md-5 .adding-students.col-md-5
h4.m-b-2 h4.m-b-2
@ -103,19 +108,21 @@ block content
+copyCodes +copyCodes
+addStudentsButton +addStudentsButton
ul.nav.nav-tabs.m-t-5(role='tablist') ul#student-info-tabs.nav.nav-tabs.m-t-5(role='tablist')
li.active li(class=(state.get('activeTab')==="#students-tab" ? 'active' : ''))
a(href='#students-tab' data-toggle='tab') a.students-tab-btn(href='#students-tab')
.small-details.text-center(data-i18n='teacher.students') .small-details.text-center(data-i18n='teacher.students')
.tab-spacer .tab-spacer
li li(class=(state.get('activeTab')==="#course-progress-tab" ? 'active' : ''))
a(href='#course-progress-tab' data-toggle='tab') a.course-progress-tab-btn(href='#course-progress-tab')
.small-details.text-center(data-i18n='teacher.course_progress') .small-details.text-center(data-i18n='teacher.course_progress')
.tab-filler .tab-filler
.tab-content .tab-content
+studentsTab if state.get('activeTab')=='#students-tab'
+courseProgressTab +studentsTab
else
+courseProgressTab
mixin breadcrumbs mixin breadcrumbs
.breadcrumbs .breadcrumbs
@ -153,7 +160,7 @@ mixin addStudentsButton
span(data-i18n='teacher.add_students_manually') span(data-i18n='teacher.add_students_manually')
mixin studentsTab mixin studentsTab
#students-tab.tab-pane.active #students-tab
+bulkAssignControls +bulkAssignControls
table.students-table table.students-table
thead thead
@ -165,7 +172,7 @@ mixin studentsTab
th th
+sortButtons +sortButtons
tbody tbody
each student in view.students.models each student in state.get('students').models
+studentRow(student) +studentRow(student)
mixin sortButtons mixin sortButtons
@ -199,12 +206,15 @@ mixin studentRow(student)
div div
+longLevelName(student.latestCompleteLevel) +longLevelName(student.latestCompleteLevel)
td td
if view.progressData if state.get('progressData')
each course, index in view.courses.models each trimCourse, index in view.classroom.get('courses')
- var course = view.courses.get(trimCourse._id);
- var instance = view.courseInstances.findWhere({ courseID: course.id, classroomID: classroom.id }) - var instance = view.courseInstances.findWhere({ courseID: course.id, classroomID: classroom.id })
if instance && instance.hasMember(student) if instance && instance.hasMember(student)
- var progress = view.progressData.get({ classroom: view.classroom, course: course, user: student }) - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, user: student })
+progressDot(progress, 'CS' + (index+1)) - var levelsTotal = trimCourse.levels.length
//- - var level = ???
+studentCourseProgressDot(progress, levelsTotal, level, 'CS' + (index+1))
unless student.isEnrolled() unless student.isEnrolled()
+enrollStudentButton(student) +enrollStudentButton(student)
//- td //- td
@ -219,7 +229,7 @@ mixin enrollStudentButton(student)
span(data-i18n='teacher.enroll_student') span(data-i18n='teacher.enroll_student')
mixin courseProgressTab mixin courseProgressTab
#course-progress-tab.tab-pane.m-t-3 #course-progress-tab.m-t-3
if view.courses if view.courses
.text-center .text-center
span(data-i18n='teacher.select_course') span(data-i18n='teacher.select_course')
@ -227,18 +237,50 @@ mixin courseProgressTab
select.course-select select.course-select
each trimCourse in view.classroom.get('courses') each trimCourse in view.classroom.get('courses')
- var course = view.courses.get(trimCourse._id); - var course = view.courses.get(trimCourse._id);
option(value=course.id) option(value=course.id selected=(course===state.get('selectedCourse')))
= course.get('name') = course.get('name')
if view.progressData if state.get('progressData')
.render-on-course-sync .render-on-course-sync
+courseOverview +courseOverview
.student-levels-table .student-levels-table
+sortButtons +sortButtons
each student in view.students.models each student in state.get('students').models
+studentLevelsRow(student) if _.contains(state.get('selectedCourse').members, student.id)
+studentLevelsRow(student)
//- TODO: If any students aren't assigned the course
.unassigned-students.render-on-course-sync
if state.get('selectedCourse') && state.get('selectedCourse').members.length < state.get('students').length
h2
span(data-i18n='TODO')
| Students who have not been assigned
|
span= state.get('selectedCourse').get('name')
for student in state.get('students').models
unless _.contains(state.get('selectedCourse').members, student.id)
.row.unassigned-student-row.alternating-background
.student-name.col-sm-3
= student.broadName()
.student-email.small-details.col-sm-3
= student.get('email')
.col-sm-4
.latest-completed.truncate.small
i.m-r-1
span(data-i18n='TODO')
| Latest completed
| :
+longLevelName(student.latestCompleteLevel)
.col-sm-2
if student.isEnrolled()
.assign-student-button.btn.btn-md.btn-navy.pull-right(data-user-id=student.id data-course-id=state.get('selectedCourse').id)
span(data-i18n='TODO')
| Assign Course
else
.enroll-student-button.btn.btn-md.btn-navy.pull-right(data-user-id=student.id)
span(data-i18n='TODO')
| Enroll Student
mixin courseOverview mixin courseOverview
- var course = view.selectedCourse - var course = state.get('selectedCourse')
- var levels = view.classroom.getLevels({courseID: course.id, withoutLadderLevels: true}).models - var levels = view.classroom.getLevels({courseID: course.id, withoutLadderLevels: true}).models
.course-overview-row .course-overview-row
.course-title.student-name .course-title.student-name
@ -247,8 +289,8 @@ mixin courseOverview
span(data-i18n='teacher.course_overview') span(data-i18n='teacher.course_overview')
.course-overview-progress .course-overview-progress
each level, index in levels each level, index in levels
- var progress = view.progressData.get({ classroom: view.classroom, course: course, level: level }) - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level })
+progressDot(progress, index+1) +allStudentsLevelProgressDot(progress, level, index+1)
mixin studentLevelsRow(student) mixin studentLevelsRow(student)
.student-levels-row.alternating-background .student-levels-row.alternating-background
@ -256,20 +298,35 @@ mixin studentLevelsRow(student)
div.student-name= student.broadName() div.student-name= student.broadName()
div.student-email.small-details= student.get('email') div.student-email.small-details= student.get('email')
div.student-levels-progress div.student-levels-progress
- var course = view.selectedCourse - var course = state.get('selectedCourse')
- var levels = view.classroom.getLevels({courseID: course.id, withoutLadderLevels: true}).models - var levels = view.classroom.getLevels({courseID: course.id, withoutLadderLevels: true}).models
each level, index in levels each level, index in levels
- var progress = view.progressData.get({ classroom: view.classroom, course: course, level: level, user: student }) - var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level, user: student })
+progressDot(progress, index+1) +studentLevelProgressDot(progress, level, index+1)
mixin progressDot(progress, label) mixin studentCourseProgressDot(progress, levelsTotal, level, label)
//- TODO: Refactor with TeacherClassesView jade //- TODO: Refactor with TeacherClassesView jade
//- TODO: Give classes abbreviations instead of using index? //- TODO: Give classes abbreviations instead of using index?
//- TODO: inefficient. Cache this in the view?
- dotClass = progress.completed ? 'forest' : (progress.started ? 'gold' : ''); - dotClass = progress.completed ? 'forest' : (progress.started ? 'gold' : '');
.progress-dot(class=dotClass, data-html='true', data-title=view.progressDotTemplate(progressDotContext) data-toggle='tooltip') - _.assign(progress, { levelsTotal: levelsTotal })
.progress-dot(class=dotClass, data-html='true', data-title=view.singleStudentCourseProgressDotTemplate(progress))
+progressDotLabel(label) +progressDotLabel(label)
mixin allStudentsLevelProgressDot(progress, level, levelNumber)
- dotClass = progress.completed ? 'forest' : (progress.started ? 'gold' : '');
- levelName = level.get('name')
- context = _.merge(progress, { levelName: levelName, levelNumber: levelNumber, numStudents: view.students.length })
.progress-dot.level-progress-dot(class=dotClass, data-html='true', data-title=view.allStudentsLevelProgressDotTemplate(context))
+progressDotLabel(levelNumber)
mixin studentLevelProgressDot(progress, level, levelNumber)
//- TODO: Refactor with TeacherClassesView jade
- dotClass = progress.completed ? 'forest' : (progress.started ? 'gold' : '');
- levelName = level.get('name')
- context = _.merge(progress, { levelName: levelName, levelNumber: levelNumber })
.progress-dot.level-progress-dot(class=dotClass, data-html='true', data-title=view.singleStudentLevelProgressDotTemplate(context))
+progressDotLabel(levelNumber)
mixin progressDotLabel(label) mixin progressDotLabel(label)
.dot-label.text-center .dot-label.text-center
.dot-label-inner .dot-label-inner
@ -278,23 +335,23 @@ mixin progressDotLabel(label)
mixin copyCodes mixin copyCodes
div.copy-button-group.form-inline.m-b-3 div.copy-button-group.form-inline.m-b-3
.form-group .form-group
input.text-h4.semibold#join-code-input(value=view.classCode) input.text-h4.semibold#join-code-input(value=state.get('classCode'))
button#copy-code-btn.form-control.btn.btn-lg.btn-forest button#copy-code-btn.form-control.btn.btn-lg.btn-forest
span(data-i18n='teacher.copy_class_code') span(data-i18n='teacher.copy_class_code')
div.text-center.small(data-i18n='teacher.class_code_blurb') div.text-center.small(data-i18n='teacher.class_code_blurb')
div.copy-button-group.form-inline.m-b-3 div.copy-button-group.form-inline.m-b-3
.form-group .form-group
input.form-control.text-h4.semibold#join-url-input(value=view.joinURL) input.form-control.text-h4.semibold#join-url-input(value=state.get('joinURL'))
button#copy-url-btn.form-control.btn.btn-lg.btn-forest button#copy-url-btn.form-control.btn.btn-lg.btn-forest
span(data-i18n='teacher.copy_class_url') span(data-i18n='teacher.copy_class_url')
div.text-center.small(data-i18n='teacher.class_join_url_blurb') div.text-center.small(data-i18n='teacher.class_join_url_blurb')
mixin bulkAssignControls mixin bulkAssignControls
.bulk-assign-controls.form-inline .bulk-assign-controls.form-inline
.no-students-selected.small-details(class=view.assigningToNobody ? 'visible' : '') .no-students-selected.small-details(class=state.get('errors').assigningToNobody ? 'visible' : '')
span(data-i18n='teacher.no_students_selected') span(data-i18n='teacher.no_students_selected')
.cant-assign-to-unenrolled.small-details(class=view.assigningToUnenrolled ? 'visible' : '') .cant-assign-to-unenrolled.small-details(class=state.get('errors').assigningToUnenrolled ? 'visible' : '')
span(data-i18n='teacher.cant_assign_to_unenrolled') span(data-i18n='teacher.cant_assign_to_unenrolled')
span.small span.small
span(data-i18n='teacher.bulk_assign') span(data-i18n='teacher.bulk_assign')
@ -302,7 +359,7 @@ mixin bulkAssignControls
select.bulk-course-select.form-control select.bulk-course-select.form-control
each trimCourse in _.rest(view.classroom.get('courses')) each trimCourse in _.rest(view.classroom.get('courses'))
- var course = view.courses.get(trimCourse._id) - var course = view.courses.get(trimCourse._id)
option(value=course.id) option(value=course.id selected=(course===state.get('selectedCourse')))
= course.get('name') = course.get('name')
button.btn.btn-primary-alt.assign-to-selected-students button.btn.btn-primary-alt.assign-to-selected-students
span(data-i18n='teacher.assign_to_selected_students') span(data-i18n='teacher.assign_to_selected_students')

View file

@ -0,0 +1,21 @@
if started
.small-details.nowrap
span= levelNumber
span.spr .
span= levelName
.small-details.nowrap
.fraction-students.small-details
= numStarted
| /
= numStudents
if completed
span.spl(data-i18n='teacher.completed')
| Completed
else
span.spl(data-i18n='teacher.started')
| Started
//- .small-details
//- i(data-i18n='teacher.click_to_view_progress')
else
span.small-details.nowrap(data-i18n='teacher.no_progress')
| No progress

View file

@ -0,0 +1,18 @@
if completed
span.small-details(data-i18n='teacher.complete')
| Complete
else if started
.fraction-students.small-details
= levelsCompleted
| /
= levelsTotal
span.spl(data-i18n='teacher.levels')
| Levels
.percent-students.small-details
= Math.floor(levelsCompleted / levelsTotal * 100)
| %
span.spl(data-i18n='teacher.complete')
| Complete
else
span.small-details(data-i18n='teacher.assigned')
| Assigned

View file

@ -0,0 +1,27 @@
if completed
.small-details.nowrap
span= levelNumber
span.spr .
span= levelName
.small-details.nowrap
span.spr(data-i18n='teacher.completed')
| Completed
span= new Date(dateFirstCompleted).toLocaleString()
//- .small-details
//- i(data-i18n='teacher.click_to_view_solution')
//- | click to view solution
else if started
.small-details.nowrap
span= levelNumber
span.spr .
span= levelName
.small-details.nowrap
span.spr(data-i18n='teacher.last_played')
| Last played
span= new Date(lastPlayed).toLocaleString()
//- .small-details
//- i(data-i18n='teacher.click_to_view_progress')
//- | click to view progress
else
span.small-details.nowrap(data-i18n='teacher.no_progress')
| No progress

View file

@ -220,6 +220,10 @@ block content
span.spr(data-i18n="teachers_quote.thanks_p") span.spr(data-i18n="teachers_quote.thanks_p")
a.spl(href="mailto:team@codecombat.com") team@codecombat.com a.spl(href="mailto:team@codecombat.com") team@codecombat.com
unless me.isAnonymous()
a.btn.btn-lg.btn-navy(href="/teachers/classes")
span(data-i18n='teachers_quote.back_to_classes')
if me.isAnonymous() if me.isAnonymous()
h5(data-i18n="teachers_quote.finish_signup") h5(data-i18n="teachers_quote.finish_signup")
p(data-i18n="teachers_quote.finish_signup_p") p(data-i18n="teachers_quote.finish_signup_p")

View file

@ -137,6 +137,7 @@ module.exports = class CocoView extends Backbone.View
context._ = _ context._ = _
context.document = document context.document = document
context.i18n = utils.i18n context.i18n = utils.i18n
context.state = @state
context context
afterRender: -> afterRender: ->

View file

@ -1,7 +1,9 @@
ModalView = require 'views/core/ModalView' ModalView = require 'views/core/ModalView'
State = require 'models/State'
template = require 'templates/courses/activate-licenses-modal' template = require 'templates/courses/activate-licenses-modal'
CocoCollection = require 'collections/CocoCollection' CocoCollection = require 'collections/CocoCollection'
Prepaids = require 'collections/Prepaids' Prepaids = require 'collections/Prepaids'
Classroom = require 'models/Classroom'
Classrooms = require 'collections/Classrooms' Classrooms = require 'collections/Classrooms'
User = require 'models/User' User = require 'models/User'
Users = require 'collections/Users' Users = require 'collections/Users'
@ -11,14 +13,23 @@ module.exports = class ActivateLicensesModal extends ModalView
template: template template: template
events: events:
'change input': 'updateSelectionSpans' 'change input[type="checkbox"][name="user"]': 'updateSelectedStudents'
'change select': 'replaceStudentList' 'change select.classroom-select': 'replaceStudentList'
'submit form': 'onSubmitForm' 'submit form': 'onSubmitForm'
getInitialState: (options) ->
selectedUserModels = _.filter(options.selectedUsers.models, (user) -> not user.isEnrolled())
{
selectedUsers: new Users(selectedUserModels)
visibleSelectedUsers: new Users(selectedUserModels)
error: null
}
initialize: (options) -> initialize: (options) ->
@state = new State(@getInitialState(options))
@classroom = options.classroom @classroom = options.classroom
@users = options.users @users = options.users.clone()
@selectedUsers = options.selectedUsers @users.comparator = (user) -> user.broadName().toLowerCase()
@prepaids = new Prepaids() @prepaids = new Prepaids()
@prepaids.comparator = '_id' @prepaids.comparator = '_id'
@prepaids.fetchByCreator(me.id) @prepaids.fetchByCreator(me.id)
@ -34,67 +45,57 @@ module.exports = class ActivateLicensesModal extends ModalView
}) })
@supermodel.trackCollection(@classrooms) @supermodel.trackCollection(@classrooms)
@listenTo @state, 'change', @render
@listenTo @state.get('selectedUsers'), 'change add remove reset', ->
@state.set { visibleSelectedUsers: new Users(@state.get('selectedUsers').filter (u) => @users.get(u)) }
@render()
@listenTo @users, 'change add remove reset', ->
@state.set { visibleSelectedUsers: new Users(@state.get('selectedUsers').filter (u) => @users.get(u)) }
@render()
@listenTo @prepaids, 'sync add remove', ->
@state.set {
unusedEnrollments: @prepaids.totalMaxRedeemers() - @prepaids.totalRedeemers()
}
afterRender: -> afterRender: ->
super() super()
@updateSelectionSpans() # @updateSelectedStudents() # TODO: refactor to event/state style
updateSelectionSpans: -> updateSelectedStudents: (e) ->
targets = @$('input[name="targets"]:checked').val() userID = $(e.currentTarget).data('user-id')
if targets is 'given' user = @users.get(userID)
numToActivate = 1 if @state.get('selectedUsers').contains(user)
@state.get('selectedUsers').remove(user)
else else
numToActivate = @$('input[name="user"]:checked:not(:disabled)').length @state.get('selectedUsers').add(user)
@$('#total-selected-span').text(numToActivate) # @render() # TODO: Have @state automatically listen to children's change events?
remaining = @prepaids.totalMaxRedeemers() - @prepaids.totalRedeemers() - numToActivate
depleted = remaining < 0
@$('.not-enough-enrollments').toggleClass('visible', depleted)
@$('#activate-licenses-btn').toggleClass('disabled', depleted).toggleClass('btn-success', not depleted).toggleClass('btn-default', depleted)
replaceStudentList: (e) -> replaceStudentList: (e) ->
selectedClassroomID = $(e.currentTarget).val() selectedClassroomID = $(e.currentTarget).val()
@classroom = @classrooms.get(selectedClassroomID) @classroom = @classrooms.get(selectedClassroomID)
if selectedClassroomID == 'all-classrooms' if selectedClassroomID is 'all-students'
@classroom = new Classroom({ id: 'all-students' }) # TODO: This is a horrible hack so the select shows the right option! @classroom = new Classroom({ _id: 'all-students', name: 'All Students' }) # TODO: This is a horrible hack so the select shows the right option!
users = _.uniq _.flatten @classrooms.map (classroom) -> classroom.users.models users = _.uniq _.flatten @classrooms.map (classroom) -> classroom.users.models
@users.reset(users) @users.reset(users)
@users.sort()
else else
@users.reset(@classrooms.get(selectedClassroomID).users.models) @users.reset(@classrooms.get(selectedClassroomID).users.models)
@trigger('users:change')
@render() @render()
null null
showProgress: ->
@$('#submit-form-area').addClass('hide')
@$('#progress-area').removeClass('hide')
hideProgress: ->
@$('#submit-form-area').removeClass('hide')
@$('#progress-area').addClass('hide')
onSubmitForm: (e) -> onSubmitForm: (e) ->
e.preventDefault() e.preventDefault()
@$('#error-alert').addClass('hide') @state.set error: null
@usersToRedeem = new CocoCollection([], {model: User}) usersToRedeem = @state.get('visibleSelectedUsers')
targets = @$('input[name="targets"]:checked').val() @redeemUsers(usersToRedeem)
if targets is 'given'
@usersToRedeem.add(@user)
else
checkedBoxes = @$('input[name="user"]:checked:not(:disabled)')
_.each checkedBoxes, (el) =>
$el = $(el)
userID = $el.data('user-id')
@usersToRedeem.add @users.get(userID)
return unless @usersToRedeem.size()
@usersToRedeem.originalSize = @usersToRedeem.size()
@showProgress()
@redeemUsers()
redeemUsers: -> redeemUsers: (usersToRedeem) ->
if not @usersToRedeem.size() if not usersToRedeem.size()
@finishRedeemUsers() @finishRedeemUsers()
@hide()
return return
user = @usersToRedeem.first() user = usersToRedeem.first()
prepaid = @prepaids.find((prepaid) -> prepaid.get('properties')?.endDate? and prepaid.openSpots() > 0) prepaid = @prepaids.find((prepaid) -> prepaid.get('properties')?.endDate? and prepaid.openSpots() > 0)
prepaid = @prepaids.find((prepaid) -> prepaid.openSpots() > 0) unless prepaid prepaid = @prepaids.find((prepaid) -> prepaid.openSpots() > 0) unless prepaid
$.ajax({ $.ajax({
@ -102,19 +103,20 @@ module.exports = class ActivateLicensesModal extends ModalView
url: _.result(prepaid, 'url') + '/redeemers' url: _.result(prepaid, 'url') + '/redeemers'
data: { userID: user.id } data: { userID: user.id }
context: @ context: @
success: -> success: (prepaid) ->
@usersToRedeem.remove(user) user.set('coursePrepaidID', prepaid._id)
pct = 100 * (@usersToRedeem.originalSize - @usersToRedeem.size() / @usersToRedeem.originalSize) usersToRedeem.remove(user)
@$('#progress-area .progress-bar').css('width', "#{pct.toFixed(1)}%") # pct = 100 * (usersToRedeem.originalSize - usersToRedeem.size() / usersToRedeem.originalSize)
# @$('#progress-area .progress-bar').css('width', "#{pct.toFixed(1)}%")
application.tracker?.trackEvent 'Enroll modal finished enroll student', category: 'Courses', userID: user.id application.tracker?.trackEvent 'Enroll modal finished enroll student', category: 'Courses', userID: user.id
@redeemUsers() @redeemUsers(usersToRedeem)
error: (jqxhr, textStatus, errorThrown) -> error: (jqxhr, textStatus, errorThrown) ->
if jqxhr.status is 402 if jqxhr.status is 402
message = arguments[2] message = arguments[2]
else else
message = "#{jqxhr.status}: #{jqxhr.responseText}" message = "#{jqxhr.status}: #{jqxhr.responseText}"
@$('#error-alert').text(message).removeClass('hide') @state.set { error: message } # TODO: Test this! ("should" never happen. Only on server responding with an error.)
}) })
finishRedeemUsers: -> finishRedeemUsers: ->
@trigger 'redeem-users' @trigger 'redeem-users', @state.get('selectedUsers')

View file

@ -1,4 +1,5 @@
RootView = require 'views/core/RootView' RootView = require 'views/core/RootView'
State = require 'models/State'
template = require 'templates/courses/teacher-class-view' template = require 'templates/courses/teacher-class-view'
helper = require 'lib/coursesHelper' helper = require 'lib/coursesHelper'
ClassroomSettingsModal = require 'views/courses/ClassroomSettingsModal' ClassroomSettingsModal = require 'views/courses/ClassroomSettingsModal'
@ -12,6 +13,7 @@ Levels = require 'collections/Levels'
LevelSessions = require 'collections/LevelSessions' LevelSessions = require 'collections/LevelSessions'
User = require 'models/User' User = require 'models/User'
Users = require 'collections/Users' Users = require 'collections/Users'
Course = require 'models/Course'
Courses = require 'collections/Courses' Courses = require 'collections/Courses'
CourseInstance = require 'models/CourseInstance' CourseInstance = require 'models/CourseInstance'
CourseInstances = require 'collections/CourseInstances' CourseInstances = require 'collections/CourseInstances'
@ -21,6 +23,13 @@ module.exports = class TeacherClassView extends RootView
template: template template: template
events: events:
'click .students-tab-btn': (e) ->
e.preventDefault()
@trigger 'open-students-tab'
'click .course-progress-tab-btn': (e) ->
e.preventDefault()
@trigger 'open-course-progress-tab'
'click .unarchive-btn': 'onClickUnarchive'
'click .edit-classroom': 'onClickEditClassroom' 'click .edit-classroom': 'onClickEditClassroom'
'click .add-students-btn': 'onClickAddStudents' 'click .add-students-btn': 'onClickAddStudents'
'click .sort-by-name': 'sortByName' 'click .sort-by-name': 'sortByName'
@ -28,32 +37,58 @@ module.exports = class TeacherClassView extends RootView
'click #copy-url-btn': 'copyURL' 'click #copy-url-btn': 'copyURL'
'click #copy-code-btn': 'copyCode' 'click #copy-code-btn': 'copyCode'
'click .remove-student-link': 'onClickRemoveStudentLink' 'click .remove-student-link': 'onClickRemoveStudentLink'
'click .assign-student-button': 'onClickAssign'
'click .enroll-student-button': 'onClickEnroll' 'click .enroll-student-button': 'onClickEnroll'
'click .assign-to-selected-students': 'onClickBulkAssign' 'click .assign-to-selected-students': 'onClickBulkAssign'
'click .enroll-selected-students': 'onClickBulkEnroll' 'click .enroll-selected-students': 'onClickBulkEnroll'
'click .export-student-progress-btn': 'onClickExportStudentProgress' 'click .export-student-progress-btn': 'onClickExportStudentProgress'
'click .select-all': 'onClickSelectAll' 'click .select-all': 'onClickSelectAll'
'click .student-checkbox': 'onClickStudentCheckbox' 'click .student-checkbox': 'onClickStudentCheckbox'
'change .course-select': 'onChangeCourseSelect' 'change .course-select, .bulk-course-select': (e) ->
@trigger 'course-select:change', { selectedCourse: @courses.get($(e.currentTarget).val()) }
getInitialState: ->
if Backbone.history.getHash() in ['students-tab', 'course-progress-tab']
activeTab = '#' + Backbone.history.getHash()
else
activeTab = '#students-tab'
{
sortAttribute: 'name'
sortDirection: 1
activeTab
students: new Users()
classCode: ""
joinURL: ""
errors:
assigningToNobody: false
assigningToUnenrolled: false
selectedCourse: undefined
classStats:
averagePlaytime: ""
totalPlaytime: ""
averageLevelsComplete: ""
totalLevelsComplete: ""
enrolledUsers: ""
}
initialize: (options, classroomID) -> initialize: (options, classroomID) ->
super(options) super(options)
@progressDotTemplate = require 'templates/courses/progress-dot' @singleStudentCourseProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-single-student-course'
@singleStudentLevelProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-single-student-level'
@allStudentsLevelProgressDotTemplate = require 'templates/teachers/hovers/progress-dot-all-students-single-level'
@sortAttribute = 'name' @state = new State(@getInitialState())
@sortDirection = 1 window.location.hash = @state.get('activeTab') # TODO: Don't push to URL history (maybe don't use url fragment for default tab)
@classroom = new Classroom({ _id: classroomID }) @classroom = new Classroom({ _id: classroomID })
@classroom.fetch() @classroom.fetch()
@supermodel.trackModel(@classroom) @supermodel.trackModel(@classroom)
@students = new Users()
@listenTo @classroom, 'sync', -> @listenTo @classroom, 'sync', ->
@students = new Users()
jqxhrs = @students.fetchForClassroom(@classroom, removeDeleted: true) jqxhrs = @students.fetchForClassroom(@classroom, removeDeleted: true)
if jqxhrs.length > 0 if jqxhrs.length > 0
@supermodel.trackCollection(@students) @supermodel.trackCollection(@students)
@listenTo @students, 'sync', @sortByName
@listenTo @students, 'sort', @renderSelectors.bind(@, '.students-table', '.student-levels-table')
@classroom.sessions = new LevelSessions() @classroom.sessions = new LevelSessions()
requests = @classroom.sessions.fetchForAllClassroomMembers(@classroom) requests = @classroom.sessions.fetchForAllClassroomMembers(@classroom)
@ -71,42 +106,114 @@ module.exports = class TeacherClassView extends RootView
@levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts'}}) @levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts'}})
@supermodel.trackCollection(@levels) @supermodel.trackCollection(@levels)
@attachMediatorEvents()
attachMediatorEvents: () ->
@listenTo @state, 'sync change', @render
# Model/Collection events
@listenTo @classroom, 'sync change update', ->
@removeDeletedStudents()
classCode = @classroom.get('codeCamel') or @classroom.get('code')
@state.set {
classCode: classCode
joinURL: document.location.origin + "/courses?_cc=" + classCode
}
@listenTo @courses, 'sync change update', ->
@setCourseMembers() # Is this necessary?
@state.set selectedCourse: @courses.first() unless @state.get('selectedCourse')
@listenTo @courseInstances, 'sync change update', ->
@setCourseMembers()
@render() # TODO: use state
@listenTo @courseInstances, 'add-members', ->
noty text: $.i18n.t('teacher.assigned'), layout: 'center', type: 'information', killer: true, timeout: 5000
@listenToOnce @students, 'sync', # TODO: This seems like it's in the wrong place?
@sortByName
@listenTo @students, 'sync change update add remove reset', ->
# Set state/props of things that depend on students?
# Set specific parts of state based on the models, rather than just dumping the collection there?
@removeDeletedStudents()
@calculateProgressAndLevels()
classStats = @calculateClassStats()
@state.set classStats: classStats if classStats
@state.set students: @students
@listenTo @students, 'sort', ->
@state.set students: @students
@render()
# DOM events
@listenTo @, 'open-students-tab', ->
if window.location.hash isnt '#students-tab'
window.location.hash = '#students-tab'
@state.set activeTab: '#students-tab'
@listenTo @, 'open-course-progress-tab', ->
if window.location.hash isnt '#course-progress-tab'
window.location.hash = '#course-progress-tab'
@state.set activeTab: '#course-progress-tab'
@listenTo @, 'course-select:change', ({ selectedCourse }) ->
@state.set selectedCourse: selectedCourse
setCourseMembers: =>
for course in @courses.models
course.instance = @courseInstances.findWhere({ courseID: course.id, classroomID: @classroom.id })
course.members = course.instance?.get('members') or []
null
onLoaded: -> onLoaded: ->
@removeDeletedStudents() @removeDeletedStudents() # TODO: Move this to mediator listeners? For both classroom and students?
@calculateProgressAndLevels()
super()
@classCode = @classroom.get('codeCamel') or @classroom.get('code') afterRender: ->
@joinURL = document.location.origin + "/courses?_cc=" + @classCode super(arguments...)
$('.progress-dot').each (i, el) ->
dot = $(el)
dot.tooltip({
html: true
container: dot
}).delegate '.tooltip', 'mousemove', ->
dot.tooltip('hide')
@earliestIncompleteLevel = helper.calculateEarliestIncomplete(@classroom, @courses, @courseInstances, @students) calculateProgressAndLevels: ->
@latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, @students) return unless @supermodel.progress is 1
# TODO: How to structure this in @state?
for student in @students.models for student in @students.models
# TODO: this is a weird hack # TODO: this is a weird hack
studentsStub = new Users([ student ]) studentsStub = new Users([ student ])
student.latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, studentsStub) student.latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, studentsStub)
classroomsStub = new Classrooms([ @classroom ]) earliestIncompleteLevel = helper.calculateEarliestIncomplete(@classroom, @courses, @courseInstances, @students)
@progressData = helper.calculateAllProgress(classroomsStub, @courses, @courseInstances, @students) latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, @students)
# @conceptData = helper.calculateConceptsCovered(classroomsStub, @courses, @campaigns, @courseInstances, @students)
@selectedCourse = @courses.first() classroomsStub = new Classrooms([ @classroom ])
super() progressData = helper.calculateAllProgress(classroomsStub, @courses, @courseInstances, @students)
# conceptData: helper.calculateConceptsCovered(classroomsStub, @courses, @campaigns, @courseInstances, @students)
@state.set {
earliestIncompleteLevel
latestCompleteLevel
progressData
classStats: @calculateClassStats()
}
copyCode: -> copyCode: ->
@$('#join-code-input').val(@classCode).select() @$('#join-code-input').val(@state.get('classCode')).select()
@tryCopy() @tryCopy()
copyURL: -> copyURL: ->
@$('#join-url-input').val(@joinURL).select() @$('#join-url-input').val(@state.get('joinURL')).select()
@tryCopy() @tryCopy()
tryCopy: -> tryCopy: ->
try try
document.execCommand('copy') document.execCommand('copy')
application.tracker?.trackEvent 'Classroom copy URL', category: 'Courses', classroomID: @classroom.id, url: @joinURL application.tracker?.trackEvent 'Classroom copy URL', category: 'Courses', classroomID: @classroom.id, url: @state.joinURL
catch err catch err
message = 'Oops, unable to copy' message = 'Oops, unable to copy'
noty text: message, layout: 'topCenter', type: 'error', killer: false noty text: message, layout: 'topCenter', type: 'error', killer: false
onClickUnarchive: ->
@classroom.save { archived: false }
onClickEditClassroom: (e) -> onClickEditClassroom: (e) ->
classroom = @classroom classroom = @classroom
modal = new ClassroomSettingsModal({ classroom: classroom }) modal = new ClassroomSettingsModal({ classroom: classroom })
@ -125,7 +232,6 @@ module.exports = class TeacherClassView extends RootView
onStudentRemoved: (e) -> onStudentRemoved: (e) ->
@students.remove(e.user) @students.remove(e.user)
@render()
application.tracker?.trackEvent 'Classroom removed student', category: 'Courses', classroomID: @classroom.id, userID: e.user.id application.tracker?.trackEvent 'Classroom removed student', category: 'Courses', classroomID: @classroom.id, userID: e.user.id
onClickAddStudents: (e) => onClickAddStudents: (e) =>
@ -134,31 +240,32 @@ module.exports = class TeacherClassView extends RootView
@listenToOnce modal, 'hide', @render @listenToOnce modal, 'hide', @render
removeDeletedStudents: () -> removeDeletedStudents: () ->
return unless @classroom.loaded and @students.loaded
_.remove(@classroom.get('members'), (memberID) => _.remove(@classroom.get('members'), (memberID) =>
not @students.get(memberID) or @students.get(memberID)?.get('deleted') not @students.get(memberID) or @students.get(memberID)?.get('deleted')
) )
true true
sortByName: (e) -> sortByName: (e) ->
if @sortValue is 'name' if @state.get('sortValue') is 'name'
@sortDirection = -@sortDirection @state.set('sortDirection', -@state.get('sortDirection'))
else else
@sortValue = 'name' @state.set('sortValue', 'name')
@sortDirection = 1 @state.set('sortDirection', 1)
dir = @sortDirection dir = @state.get('sortDirection')
@students.comparator = (student1, student2) -> @students.comparator = (student1, student2) ->
return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir) return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir)
@students.sort() @students.sort()
sortByProgress: (e) -> sortByProgress: (e) ->
if @sortValue is 'progress' if @state.get('sortValue') is 'progress'
@sortDirection = -@sortDirection @state.set('sortDirection', -@state.get('sortDirection'))
else else
@sortValue = 'progress' @state.set('sortValue', 'progress')
@sortDirection = 1 @state.set('sortDirection', 1)
dir = @sortDirection dir = @state.get('sortDirection')
@students.comparator = (student) -> @students.comparator = (student) ->
#TODO: I would like for this to be in the Level model, #TODO: I would like for this to be in the Level model,
@ -179,19 +286,24 @@ module.exports = class TeacherClassView extends RootView
userID = $(e.currentTarget).data('user-id') userID = $(e.currentTarget).data('user-id')
user = @students.get(userID) user = @students.get(userID)
selectedUsers = new Users([user]) selectedUsers = new Users([user])
modal = new ActivateLicensesModal { @classroom, selectedUsers, users: @students } @enrollStudents(selectedUsers)
@openModalView(modal)
modal.once 'redeem-users', -> document.location.reload()
application.tracker?.trackEvent 'Classroom started enroll students', category: 'Courses'
onClickBulkEnroll: -> onClickBulkEnroll: ->
courseID = @$('.bulk-course-select').val() courseID = @$('.bulk-course-select').val()
courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id }) courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id })
userIDs = @getSelectedStudentIDs().toArray() userIDs = @getSelectedStudentIDs().toArray()
selectedUsers = new Users(@students.get(userID) for userID in userIDs) selectedUsers = new Users(@students.get(userID) for userID in userIDs)
@enrollStudents(selectedUsers)
enrollStudents: (selectedUsers) ->
modal = new ActivateLicensesModal { @classroom, selectedUsers, users: @students } modal = new ActivateLicensesModal { @classroom, selectedUsers, users: @students }
@openModalView(modal) @openModalView(modal)
modal.once 'redeem-users', -> document.location.reload() modal.once 'redeem-users', (enrolledUsers) =>
enrolledUsers.each (newUser) =>
user = @students.get(newUser.id)
if user
user.set(newUser.attributes)
null
application.tracker?.trackEvent 'Classroom started enroll students', category: 'Courses' application.tracker?.trackEvent 'Classroom started enroll students', category: 'Courses'
onClickExportStudentProgress: -> onClickExportStudentProgress: ->
@ -201,10 +313,10 @@ module.exports = class TeacherClassView extends RootView
concepts = [] concepts = []
for course, index in @courses.models for course, index in @courses.models
instance = @courseInstances.findWhere({ courseID: course.id, classroomID: @classroom.id }) instance = @courseInstances.findWhere({ courseID: course.id, classroomID: @classroom.id })
if instance && instance.hasMember(student) if instance and instance.hasMember(student)
# TODO: @levels collection is for the classroom, and not per-course # TODO: @levels collection is for the classroom, and not per-course
for level, index in @levels.models for level, index in @levels.models
progress = @progressData.get({ classroom: @classroom, course: course, level: level, user: student }) progress = @state.get('progressData').get({ classroom: @classroom, course: course, level: level, user: student })
concepts.push(level.get('concepts') ? []) if progress?.completed concepts.push(level.get('concepts') ? []) if progress?.completed
concepts = _.union(_.flatten(concepts)) concepts = _.union(_.flatten(concepts))
conceptsString = _.map(concepts, (c) -> $.i18n.t("concepts." + c)).join(', ') conceptsString = _.map(concepts, (c) -> $.i18n.t("concepts." + c)).join(', ')
@ -217,27 +329,37 @@ module.exports = class TeacherClassView extends RootView
encodedUri = encodeURI(csvContent) encodedUri = encodeURI(csvContent)
window.open(encodedUri) window.open(encodedUri)
onClickAssign: (e) ->
userID = $(e.currentTarget).data('user-id')
user = @students.get(userID)
members = [userID]
courseID = $(e.currentTarget).data('course-id')
@assignCourse courseID, members
onClickBulkAssign: -> onClickBulkAssign: ->
courseID = @$('.bulk-course-select').val() courseID = @$('.bulk-course-select').val()
courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id })
selectedIDs = @getSelectedStudentIDs() selectedIDs = @getSelectedStudentIDs()
members = selectedIDs.filter((index, userID) => members = selectedIDs.filter((index, userID) =>
user = @students.get(userID) user = @students.get(userID)
user.isEnrolled() user.isEnrolled()
).toArray() ).toArray()
@assigningToUnenrolled = _.any selectedIDs, (userID) => assigningToUnenrolled = _.any selectedIDs, (userID) =>
not @students.get(userID).isEnrolled() not @students.get(userID).isEnrolled()
@$('.cant-assign-to-unenrolled').toggleClass('visible', @assigningToUnenrolled) assigningToNobody = selectedIDs.length is 0
@assigningToNobody = selectedIDs.length is 0 @state.set errors: { assigningToNobody, assigningToUnenrolled }
@$('.no-students-selected').toggleClass('visible', @assigningToNobody)
@assignCourse courseID, members
# TODO: Move this to the model. Use promises/callbacks?
assignCourse: (courseID, members) ->
courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id })
if courseInstance if courseInstance
courseInstance.addMembers members, { courseInstance.addMembers members
success: @onBulkAssignSuccess
}
else else
courseInstance = new CourseInstance { courseInstance = new CourseInstance {
courseID, courseID,
@ -247,17 +369,11 @@ module.exports = class TeacherClassView extends RootView
} }
@courseInstances.add(courseInstance) @courseInstances.add(courseInstance)
courseInstance.save {}, { courseInstance.save {}, {
success: => success: ->
courseInstance.addMembers members, { courseInstance.addMembers members
success: @onBulkAssignSuccess
}
} }
null null
onBulkAssignSuccess: =>
@render() unless @destroyed
noty text: $.i18n.t('teacher.assigned'), layout: 'center', type: 'information', killer: true, timeout: 5000
onClickSelectAll: (e) -> onClickSelectAll: (e) ->
e.preventDefault() e.preventDefault()
checkboxes = @$('.student-checkbox input') checkboxes = @$('.student-checkbox input')
@ -278,11 +394,8 @@ module.exports = class TeacherClassView extends RootView
checkboxes = @$('.student-checkbox input') checkboxes = @$('.student-checkbox input')
@$('.select-all input').prop('checked', _.all(checkboxes, 'checked')) @$('.select-all input').prop('checked', _.all(checkboxes, 'checked'))
onChangeCourseSelect: (e) -> calculateClassStats: ->
@selectedCourse = @courses.get($(e.currentTarget).val()) return {} unless @classroom.sessions?.loaded and @students.loaded
@renderSelectors('.render-on-course-sync')
classStats: ->
stats = {} stats = {}
playtime = 0 playtime = 0
@ -301,4 +414,5 @@ module.exports = class TeacherClassView extends RootView
enrolledUsers = @students.filter (user) -> user.get('coursePrepaidID') enrolledUsers = @students.filter (user) -> user.get('coursePrepaidID')
stats.enrolledUsers = _.size(enrolledUsers) stats.enrolledUsers = _.size(enrolledUsers)
return stats return stats

View file

@ -44,7 +44,7 @@ module.exports = class TeacherClassesView extends RootView
@courseInstances = new CourseInstances() @courseInstances = new CourseInstances()
@courseInstances.fetchByOwner(me.id) @courseInstances.fetchByOwner(me.id)
@supermodel.trackCollection(@courseInstances) @supermodel.trackCollection(@courseInstances)
@progressDotTemplate = require 'templates/courses/progress-dot' @progressDotTemplate = require 'templates/teachers/hovers/progress-dot-whole-course'
# Level Sessions loaded after onLoaded to prevent race condition in calculateDots # Level Sessions loaded after onLoaded to prevent race condition in calculateDots

View file

@ -85,7 +85,7 @@ module.exports =
members = classroom.get('members') or [] members = classroom.get('members') or []
members = members.slice(memberSkip, memberSkip + memberLimit) members = members.slice(memberSkip, memberSkip + memberLimit)
dbqs = [] dbqs = []
select = 'state.complete level creator playtime' select = 'state.complete level creator playtime changed dateFirstCompleted'
for member in members for member in members
dbqs.push(LevelSession.find({creator: member.toHexString()}).select(select).exec()) dbqs.push(LevelSession.find({creator: member.toHexString()}).select(select).exec())
results = yield dbqs results = yield dbqs

View file

@ -45,7 +45,8 @@ LevelSessionSchema.post 'init', (doc) ->
LevelSessionSchema.pre 'save', (next) -> LevelSessionSchema.pre 'save', (next) ->
User = require './User' # Avoid mutual inclusion cycles User = require './User' # Avoid mutual inclusion cycles
Level = require './Level' Level = require './Level'
@set('changed', new Date()) now = new Date()
@set('changed', now)
id = @get('id') id = @get('id')
initd = @previousStateInfo? initd = @previousStateInfo?
@ -55,6 +56,7 @@ LevelSessionSchema.pre 'save', (next) ->
# Newly completed level # Newly completed level
if not (initd and @previousStateInfo['state.complete']) and @get('state.complete') if not (initd and @previousStateInfo['state.complete']) and @get('state.complete')
@set('dateFirstCompleted', now)
Level.findOne({slug: levelID}).select('concepts -_id').lean().exec (err, level) -> Level.findOne({slug: levelID}).select('concepts -_id').lean().exec (err, level) ->
log.error err if err? log.error err if err?
update = $inc: {'stats.gamesCompleted': 1} update = $inc: {'stats.gamesCompleted': 1}