mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-23 15:48:11 -05:00
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:
parent
0ed99565d3
commit
8223122a6b
26 changed files with 564 additions and 227 deletions
|
@ -139,16 +139,21 @@ module.exports =
|
|||
levels = classroom.getLevels({courseID: course.id, withoutLadderLevels: true})
|
||||
for level in levels.models
|
||||
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
|
||||
userID = user.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
|
||||
session = _.find classroom.sessions.models, (session) ->
|
||||
session.get('creator') is userID and session.get('level').original is levelID
|
||||
|
||||
|
||||
if not session # haven't gotten to this level yet, but might have completed others before
|
||||
courseProgress.started ||= false #no-op
|
||||
courseProgress.completed = false
|
||||
|
@ -158,21 +163,30 @@ module.exports =
|
|||
courseProgress[levelID].completed = false
|
||||
courseProgress[levelID][userID].started = false
|
||||
courseProgress[levelID][userID].completed = false
|
||||
|
||||
if session # have gotten to the level and at least started it
|
||||
courseProgress.started = true
|
||||
courseProgress[userID].started = true
|
||||
courseProgress[levelID].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
|
||||
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].numCompleted += 1
|
||||
courseProgress[levelID][userID].completed = true
|
||||
courseProgress[levelID][userID].dateFirstCompleted = new Date(session.get('dateFirstCompleted') || session.get('changed'))
|
||||
else # level started but not completed
|
||||
courseProgress.completed = false
|
||||
courseProgress[userID].completed = false
|
||||
courseProgress[levelID].completed = false
|
||||
courseProgress[levelID][userID].completed = false
|
||||
courseProgress[levelID].dateFirstCompleted = null
|
||||
courseProgress[levelID][userID].dateFirstCompleted = null
|
||||
|
||||
_.assign(progressData, progressMixin)
|
||||
return progressData
|
||||
|
|
|
@ -831,6 +831,7 @@
|
|||
thanks_header: "Request Received!"
|
||||
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:"
|
||||
back_to_classes: "Back to Classes"
|
||||
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."
|
||||
signup_with: "Sign up with:"
|
||||
|
@ -1291,6 +1292,7 @@
|
|||
view_class: "view class"
|
||||
archive_class: "archive class"
|
||||
unarchive_class: "unarchive class"
|
||||
unarchive_this_class: "Unarchive this class"
|
||||
no_students_yet: "This class has no students yet."
|
||||
add_students: "Add Students"
|
||||
create_new_class: "Create a New Class"
|
||||
|
@ -1312,6 +1314,10 @@
|
|||
latest_completed: "Latest Completed"
|
||||
sort_by: "Sort by"
|
||||
progress: "Progress"
|
||||
completed: "Completed"
|
||||
started: "Started"
|
||||
click_to_view_progress: "click to view progress"
|
||||
no_progress: "No progress"
|
||||
select_course: "Select course to view"
|
||||
course_overview: "Course Overview"
|
||||
copy_class_code: "Copy Class Code"
|
||||
|
|
|
@ -21,7 +21,7 @@ module.exports = class CourseInstance extends CocoModel
|
|||
data: { userID: userID }
|
||||
}
|
||||
_.extend options, opts
|
||||
@fetch(options)
|
||||
@fetch options
|
||||
if userID is me.id
|
||||
unless me.get('courseInstances')
|
||||
me.set('courseInstances', [])
|
||||
|
@ -32,6 +32,8 @@ module.exports = class CourseInstance extends CocoModel
|
|||
method: 'POST'
|
||||
url: _.result(@, 'url') + '/members'
|
||||
data: { userIDs }
|
||||
success: =>
|
||||
@trigger 'add-members', { userIDs }
|
||||
}
|
||||
_.extend options, opts
|
||||
@fetch(options)
|
||||
|
|
5
app/models/State.coffee
Normal file
5
app/models/State.coffee
Normal file
|
@ -0,0 +1,5 @@
|
|||
CocoModel = require './CocoModel'
|
||||
schema = require 'schemas/models/poll.schema'
|
||||
|
||||
module.exports = class State extends CocoModel
|
||||
@className: 'State'
|
|
@ -54,6 +54,10 @@ _.extend LevelSessionSchema.properties,
|
|||
changed: c.date
|
||||
title: 'Changed'
|
||||
readOnly: true
|
||||
|
||||
dateFirstCompleted: c.stringDate
|
||||
title: 'Completed'
|
||||
readOnly: true
|
||||
|
||||
team: c.shortString()
|
||||
level: LevelSessionLevelSchema
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@import "app/styles/bootstrap/variables"
|
||||
@import "app/styles/mixins"
|
||||
@import "app/styles/style-flat"
|
||||
@import "app/styles/style-flat-variables"
|
||||
|
||||
#about-view
|
||||
|
||||
|
@ -331,4 +331,4 @@
|
|||
content: ""
|
||||
display: block
|
||||
height: 55px
|
||||
margin: -55px 0 0 0
|
||||
margin: -55px 0 0 0
|
||||
|
|
|
@ -1,4 +1,13 @@
|
|||
#activate-licenses-modal
|
||||
select
|
||||
min-width: 80%
|
||||
|
||||
.checkbox
|
||||
margin: 0
|
||||
|
||||
input[type='checkbox']
|
||||
margin-top: 8px
|
||||
|
||||
.modal-content
|
||||
padding: 60px
|
||||
width: 690px
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
@import "app/styles/bootstrap/variables"
|
||||
@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)
|
||||
background-color: #ebebeb
|
||||
|
@ -187,6 +190,26 @@
|
|||
|
||||
.progress-dot
|
||||
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
|
||||
.checkbox-flat
|
||||
|
@ -255,4 +278,4 @@
|
|||
float: right
|
||||
|
||||
.export-student-progress-btn
|
||||
margin-top: 10px
|
||||
margin-top: 10px
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@import "app/styles/bootstrap/variables"
|
||||
@import "app/styles/mixins"
|
||||
@import "app/styles/style-flat"
|
||||
@import "app/styles/style-flat-variables"
|
||||
|
||||
#teacher-classes-view
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@import "app/styles/bootstrap/variables"
|
||||
@import "app/styles/mixins"
|
||||
@import "app/styles/style-flat"
|
||||
@import "app/styles/style-flat-variables"
|
||||
|
||||
#teacher-dashboard-nav
|
||||
vertical-align: middle
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@import "app/styles/bootstrap/variables"
|
||||
@import "app/styles/mixins"
|
||||
@import "app/styles/style-flat"
|
||||
@import "app/styles/style-flat-variables"
|
||||
|
||||
#new-home-view
|
||||
|
||||
|
|
7
app/styles/style-flat-variables.sass
Normal file
7
app/styles/style-flat-variables.sass
Normal file
|
@ -0,0 +1,7 @@
|
|||
$headline-font: 'Arvo', serif
|
||||
$body-font: 'Open Sans', sans-serif
|
||||
|
||||
$burgandy: #7D0101
|
||||
$gold: #F2BE19
|
||||
$navy: #0E4C60
|
||||
$forest: #20572B
|
|
@ -1,13 +1,9 @@
|
|||
@import "app/styles/bootstrap/variables"
|
||||
@import "app/styles/mixins"
|
||||
@import "app/styles/style-flat-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']
|
||||
// Google Fonts version of Arvo only has Latin glyphs, not Cyrillic
|
||||
// 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
|
||||
font-family: 'Open Sans', serif
|
||||
|
||||
$burgandy: #7D0101
|
||||
$gold: #F2BE19
|
||||
$navy: #0E4C60
|
||||
$forest: #20572B
|
||||
|
||||
.style-flat
|
||||
background: white
|
||||
color: black
|
||||
|
@ -194,6 +185,15 @@ $forest: #20572B
|
|||
color: $gold
|
||||
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
|
||||
|
||||
.btn
|
||||
|
@ -272,6 +272,8 @@ $forest: #20572B
|
|||
// TODO: Font size 18? Inconsistent with buttons on teacher-class-view bulk assign
|
||||
|
||||
// Tooltips
|
||||
.tooltip.in
|
||||
opacity: 1
|
||||
|
||||
.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
|
||||
|
@ -334,6 +336,7 @@ $forest: #20572B
|
|||
background: white
|
||||
border-radius: 20px
|
||||
min-width: 150px
|
||||
max-width: 600px
|
||||
|
||||
|
||||
// Checkboxes
|
||||
|
|
|
@ -5,49 +5,67 @@ block modal-header-content
|
|||
.text-center
|
||||
h1(data-i18n="teacher.enroll_students")
|
||||
h2(data-i18n="courses.grants_lifetime_access")
|
||||
if view.classroom
|
||||
p= view.classroom.get('name')
|
||||
|
||||
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
|
||||
.text-center
|
||||
span(data-i18n='teacher.show_students_from')
|
||||
span.spr :
|
||||
select
|
||||
each classroom in view.classrooms.models
|
||||
option(selected=(classroom.id === view.classroom.id), value=classroom.id)
|
||||
= classroom.get('name')
|
||||
//- option(selected=!view.classroom, value='all-classrooms' data-i18n='teacher.all_students')
|
||||
form.form
|
||||
.row
|
||||
.col-sm-10.col-sm-offset-1
|
||||
.text-center.m-b-3
|
||||
.small.color-navy
|
||||
span(data-i18n='teacher.show_students_from')
|
||||
span.spr :
|
||||
select.classroom-select
|
||||
each classroom in view.classrooms.models
|
||||
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 :
|
||||
.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
|
||||
label
|
||||
- var paid = user.get('coursePrepaidID')
|
||||
- var selected = (view.selectedUsers.get(user.id) ? true : false)
|
||||
input(type="checkbox", disabled=paid, checked=selected, data-user-id=user.id, name='user')
|
||||
input.user-checkbox(type="checkbox", disabled=false, checked=selected, data-user-id=user.id, name='user')
|
||||
span.spr= user.broadName()
|
||||
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()
|
||||
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
|
||||
p.small-details.not-enough-enrollments(class=(tooManySelected ? 'visible' : ''))
|
||||
span(data-i18n='teacher.not_enough_enrollments')
|
||||
|
||||
p.small-details
|
||||
span.spr(data-i18n="courses.enrollment_credits_available")
|
||||
span#total-available= view.prepaids.totalAvailable()
|
||||
|
||||
p.small-details.not-enough-enrollments
|
||||
span(data-i18n='teacher.not_enough_enrollments')
|
||||
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#total-selected-span
|
||||
= numToEnroll
|
||||
| )
|
||||
span.spl(data-i18n="courses.students1")
|
||||
|
||||
|
|
|
@ -18,6 +18,11 @@ block content
|
|||
if classroom.loaded
|
||||
.container
|
||||
+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')
|
||||
a.label.edit-classroom(data-classroom-id=classroom.id)
|
||||
span(data-i18n='teacher.edit_class_settings')
|
||||
|
@ -25,7 +30,7 @@ block content
|
|||
|
||||
.classroom-info-row.row.m-t-5
|
||||
.classroom-details.col-md-3
|
||||
- var stats = view.classStats()
|
||||
- var stats = state.get('classStats')
|
||||
h4.m-b-2(data-i18n='teacher.class_overview')
|
||||
|
||||
.language.small-details
|
||||
|
@ -68,55 +73,57 @@ block content
|
|||
span(data-i18n='teacher.export_student_progress')
|
||||
|
||||
//- .concepts.small-details
|
||||
//- if view.progressData
|
||||
//- if state.get('progressData')
|
||||
//- div
|
||||
//- span(data-i18n='teacher.concepts_covered')
|
||||
//- span :
|
||||
//- - console.log('concepts', view.conceptData)
|
||||
//- - concepts = view.conceptData
|
||||
//- each state, name in view.conceptData[view.classroom.id]
|
||||
//- if state.started
|
||||
//- b.concept(class=state.completed ? 'forest' : 'gold')
|
||||
//- if state.get('started')
|
||||
//- b.concept(class=state.get('completed') ? 'forest' : 'gold')
|
||||
//- span(data-i18n='concepts.'+name)
|
||||
|
||||
.completeness-info.col-md-4
|
||||
h4.m-b-2
|
||||
|
||||
if view.earliestIncompleteLevel
|
||||
if state.get('earliestIncompleteLevel')
|
||||
div.small-details
|
||||
span(data-i18n='teacher.earliest_incomplete')
|
||||
span :
|
||||
+longLevelName(view.earliestIncompleteLevel)
|
||||
+inlineUserList(view.earliestIncompleteLevel.users)
|
||||
|
||||
if view.latestCompleteLevel
|
||||
+longLevelName(state.get('earliestIncompleteLevel'))
|
||||
+inlineUserList(state.get('earliestIncompleteLevel').users)
|
||||
|
||||
if state.get('latestCompleteLevel')
|
||||
div.small-details.m-t-3
|
||||
span(data-i18n='teacher.latest_complete')
|
||||
span :
|
||||
+longLevelName(view.latestCompleteLevel)
|
||||
+inlineUserList(view.latestCompleteLevel.users)
|
||||
|
||||
+longLevelName(state.get('latestCompleteLevel'))
|
||||
+inlineUserList(state.get('latestCompleteLevel').users)
|
||||
|
||||
.adding-students.col-md-5
|
||||
h4.m-b-2
|
||||
span(data-i18n='teacher.adding_students')
|
||||
span :
|
||||
+copyCodes
|
||||
+addStudentsButton
|
||||
|
||||
ul.nav.nav-tabs.m-t-5(role='tablist')
|
||||
li.active
|
||||
a(href='#students-tab' data-toggle='tab')
|
||||
|
||||
ul#student-info-tabs.nav.nav-tabs.m-t-5(role='tablist')
|
||||
li(class=(state.get('activeTab')==="#students-tab" ? 'active' : ''))
|
||||
a.students-tab-btn(href='#students-tab')
|
||||
.small-details.text-center(data-i18n='teacher.students')
|
||||
.tab-spacer
|
||||
li
|
||||
a(href='#course-progress-tab' data-toggle='tab')
|
||||
li(class=(state.get('activeTab')==="#course-progress-tab" ? 'active' : ''))
|
||||
a.course-progress-tab-btn(href='#course-progress-tab')
|
||||
.small-details.text-center(data-i18n='teacher.course_progress')
|
||||
.tab-filler
|
||||
|
||||
.tab-content
|
||||
+studentsTab
|
||||
+courseProgressTab
|
||||
|
||||
if state.get('activeTab')=='#students-tab'
|
||||
+studentsTab
|
||||
else
|
||||
+courseProgressTab
|
||||
|
||||
mixin breadcrumbs
|
||||
.breadcrumbs
|
||||
a(data-i18n='teacher.my_classes' href='/teachers/classes')
|
||||
|
@ -153,7 +160,7 @@ mixin addStudentsButton
|
|||
span(data-i18n='teacher.add_students_manually')
|
||||
|
||||
mixin studentsTab
|
||||
#students-tab.tab-pane.active
|
||||
#students-tab
|
||||
+bulkAssignControls
|
||||
table.students-table
|
||||
thead
|
||||
|
@ -165,7 +172,7 @@ mixin studentsTab
|
|||
th
|
||||
+sortButtons
|
||||
tbody
|
||||
each student in view.students.models
|
||||
each student in state.get('students').models
|
||||
+studentRow(student)
|
||||
|
||||
mixin sortButtons
|
||||
|
@ -199,12 +206,15 @@ mixin studentRow(student)
|
|||
div
|
||||
+longLevelName(student.latestCompleteLevel)
|
||||
td
|
||||
if view.progressData
|
||||
each course, index in view.courses.models
|
||||
if state.get('progressData')
|
||||
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 })
|
||||
if instance && instance.hasMember(student)
|
||||
- var progress = view.progressData.get({ classroom: view.classroom, course: course, user: student })
|
||||
+progressDot(progress, 'CS' + (index+1))
|
||||
- var progress = state.get('progressData').get({ classroom: view.classroom, course: course, user: student })
|
||||
- var levelsTotal = trimCourse.levels.length
|
||||
//- - var level = ???
|
||||
+studentCourseProgressDot(progress, levelsTotal, level, 'CS' + (index+1))
|
||||
unless student.isEnrolled()
|
||||
+enrollStudentButton(student)
|
||||
//- td
|
||||
|
@ -219,7 +229,7 @@ mixin enrollStudentButton(student)
|
|||
span(data-i18n='teacher.enroll_student')
|
||||
|
||||
mixin courseProgressTab
|
||||
#course-progress-tab.tab-pane.m-t-3
|
||||
#course-progress-tab.m-t-3
|
||||
if view.courses
|
||||
.text-center
|
||||
span(data-i18n='teacher.select_course')
|
||||
|
@ -227,18 +237,50 @@ mixin courseProgressTab
|
|||
select.course-select
|
||||
each trimCourse in view.classroom.get('courses')
|
||||
- var course = view.courses.get(trimCourse._id);
|
||||
option(value=course.id)
|
||||
option(value=course.id selected=(course===state.get('selectedCourse')))
|
||||
= course.get('name')
|
||||
if view.progressData
|
||||
if state.get('progressData')
|
||||
.render-on-course-sync
|
||||
+courseOverview
|
||||
.student-levels-table
|
||||
+sortButtons
|
||||
each student in view.students.models
|
||||
+studentLevelsRow(student)
|
||||
each student in state.get('students').models
|
||||
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
|
||||
- var course = view.selectedCourse
|
||||
- var course = state.get('selectedCourse')
|
||||
- var levels = view.classroom.getLevels({courseID: course.id, withoutLadderLevels: true}).models
|
||||
.course-overview-row
|
||||
.course-title.student-name
|
||||
|
@ -247,8 +289,8 @@ mixin courseOverview
|
|||
span(data-i18n='teacher.course_overview')
|
||||
.course-overview-progress
|
||||
each level, index in levels
|
||||
- var progress = view.progressData.get({ classroom: view.classroom, course: course, level: level })
|
||||
+progressDot(progress, index+1)
|
||||
- var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level })
|
||||
+allStudentsLevelProgressDot(progress, level, index+1)
|
||||
|
||||
mixin studentLevelsRow(student)
|
||||
.student-levels-row.alternating-background
|
||||
|
@ -256,20 +298,35 @@ mixin studentLevelsRow(student)
|
|||
div.student-name= student.broadName()
|
||||
div.student-email.small-details= student.get('email')
|
||||
div.student-levels-progress
|
||||
- var course = view.selectedCourse
|
||||
- var course = state.get('selectedCourse')
|
||||
- var levels = view.classroom.getLevels({courseID: course.id, withoutLadderLevels: true}).models
|
||||
each level, index in levels
|
||||
- var progress = view.progressData.get({ classroom: view.classroom, course: course, level: level, user: student })
|
||||
+progressDot(progress, index+1)
|
||||
|
||||
mixin progressDot(progress, label)
|
||||
- var progress = state.get('progressData').get({ classroom: view.classroom, course: course, level: level, user: student })
|
||||
+studentLevelProgressDot(progress, level, index+1)
|
||||
|
||||
mixin studentCourseProgressDot(progress, levelsTotal, level, label)
|
||||
//- TODO: Refactor with TeacherClassesView jade
|
||||
//- TODO: Give classes abbreviations instead of using index?
|
||||
//- TODO: inefficient. Cache this in the view?
|
||||
- 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)
|
||||
|
||||
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)
|
||||
.dot-label.text-center
|
||||
.dot-label-inner
|
||||
|
@ -278,23 +335,23 @@ mixin progressDotLabel(label)
|
|||
mixin copyCodes
|
||||
div.copy-button-group.form-inline.m-b-3
|
||||
.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
|
||||
span(data-i18n='teacher.copy_class_code')
|
||||
div.text-center.small(data-i18n='teacher.class_code_blurb')
|
||||
|
||||
div.copy-button-group.form-inline.m-b-3
|
||||
.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
|
||||
span(data-i18n='teacher.copy_class_url')
|
||||
div.text-center.small(data-i18n='teacher.class_join_url_blurb')
|
||||
|
||||
mixin bulkAssignControls
|
||||
.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')
|
||||
.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.small
|
||||
span(data-i18n='teacher.bulk_assign')
|
||||
|
@ -302,9 +359,9 @@ mixin bulkAssignControls
|
|||
select.bulk-course-select.form-control
|
||||
each trimCourse in _.rest(view.classroom.get('courses'))
|
||||
- var course = view.courses.get(trimCourse._id)
|
||||
option(value=course.id)
|
||||
option(value=course.id selected=(course===state.get('selectedCourse')))
|
||||
= course.get('name')
|
||||
button.btn.btn-primary-alt.assign-to-selected-students
|
||||
span(data-i18n='teacher.assign_to_selected_students')
|
||||
button.btn.btn-primary-alt.enroll-selected-students
|
||||
span(data-i18n='teacher.enroll_selected_students')
|
||||
span(data-i18n='teacher.enroll_selected_students')
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -219,6 +219,10 @@ block content
|
|||
p
|
||||
span.spr(data-i18n="teachers_quote.thanks_p")
|
||||
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()
|
||||
h5(data-i18n="teachers_quote.finish_signup")
|
||||
|
|
|
@ -137,6 +137,7 @@ module.exports = class CocoView extends Backbone.View
|
|||
context._ = _
|
||||
context.document = document
|
||||
context.i18n = utils.i18n
|
||||
context.state = @state
|
||||
context
|
||||
|
||||
afterRender: ->
|
||||
|
@ -390,7 +391,7 @@ module.exports = class CocoView extends Backbone.View
|
|||
setTimeout (=> $pointer.css transition: 'all 0.4s ease-in', transform: "rotate(#{@pointerRotation}rad) translate(-3px, #{@pointerRadialDistance}px)"), 800
|
||||
|
||||
endHighlight: ->
|
||||
@getPointer(false).css({'opacity': 0.0, 'transition': 'none', top: '-50px', right: '-50px'})
|
||||
@getPointer(false).css({'opacity': 0.0, 'transition': 'none', top: '-50px', right: '-50px'})
|
||||
clearInterval @pointerInterval
|
||||
clearTimeout @pointerDelayTimeout
|
||||
clearTimeout @pointerDurationTimeout
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
ModalView = require 'views/core/ModalView'
|
||||
State = require 'models/State'
|
||||
template = require 'templates/courses/activate-licenses-modal'
|
||||
CocoCollection = require 'collections/CocoCollection'
|
||||
Prepaids = require 'collections/Prepaids'
|
||||
Classroom = require 'models/Classroom'
|
||||
Classrooms = require 'collections/Classrooms'
|
||||
User = require 'models/User'
|
||||
Users = require 'collections/Users'
|
||||
|
@ -11,14 +13,23 @@ module.exports = class ActivateLicensesModal extends ModalView
|
|||
template: template
|
||||
|
||||
events:
|
||||
'change input': 'updateSelectionSpans'
|
||||
'change select': 'replaceStudentList'
|
||||
'change input[type="checkbox"][name="user"]': 'updateSelectedStudents'
|
||||
'change select.classroom-select': 'replaceStudentList'
|
||||
'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) ->
|
||||
@state = new State(@getInitialState(options))
|
||||
@classroom = options.classroom
|
||||
@users = options.users
|
||||
@selectedUsers = options.selectedUsers
|
||||
@users = options.users.clone()
|
||||
@users.comparator = (user) -> user.broadName().toLowerCase()
|
||||
@prepaids = new Prepaids()
|
||||
@prepaids.comparator = '_id'
|
||||
@prepaids.fetchByCreator(me.id)
|
||||
|
@ -33,68 +44,58 @@ module.exports = class ActivateLicensesModal extends ModalView
|
|||
@supermodel.trackRequests(jqxhrs)
|
||||
})
|
||||
@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: ->
|
||||
super()
|
||||
@updateSelectionSpans()
|
||||
# @updateSelectedStudents() # TODO: refactor to event/state style
|
||||
|
||||
updateSelectionSpans: ->
|
||||
targets = @$('input[name="targets"]:checked').val()
|
||||
if targets is 'given'
|
||||
numToActivate = 1
|
||||
updateSelectedStudents: (e) ->
|
||||
userID = $(e.currentTarget).data('user-id')
|
||||
user = @users.get(userID)
|
||||
if @state.get('selectedUsers').contains(user)
|
||||
@state.get('selectedUsers').remove(user)
|
||||
else
|
||||
numToActivate = @$('input[name="user"]:checked:not(:disabled)').length
|
||||
@$('#total-selected-span').text(numToActivate)
|
||||
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)
|
||||
|
||||
@state.get('selectedUsers').add(user)
|
||||
# @render() # TODO: Have @state automatically listen to children's change events?
|
||||
|
||||
replaceStudentList: (e) ->
|
||||
selectedClassroomID = $(e.currentTarget).val()
|
||||
@classroom = @classrooms.get(selectedClassroomID)
|
||||
if selectedClassroomID == 'all-classrooms'
|
||||
@classroom = new Classroom({ id: 'all-students' }) # TODO: This is a horrible hack so the select shows the right option!
|
||||
if selectedClassroomID is 'all-students'
|
||||
@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.reset(users)
|
||||
@users.sort()
|
||||
else
|
||||
@users.reset(@classrooms.get(selectedClassroomID).users.models)
|
||||
@trigger('users:change')
|
||||
@render()
|
||||
null
|
||||
|
||||
showProgress: ->
|
||||
@$('#submit-form-area').addClass('hide')
|
||||
@$('#progress-area').removeClass('hide')
|
||||
|
||||
hideProgress: ->
|
||||
@$('#submit-form-area').removeClass('hide')
|
||||
@$('#progress-area').addClass('hide')
|
||||
|
||||
onSubmitForm: (e) ->
|
||||
e.preventDefault()
|
||||
@$('#error-alert').addClass('hide')
|
||||
@usersToRedeem = new CocoCollection([], {model: User})
|
||||
targets = @$('input[name="targets"]:checked').val()
|
||||
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()
|
||||
@state.set error: null
|
||||
usersToRedeem = @state.get('visibleSelectedUsers')
|
||||
@redeemUsers(usersToRedeem)
|
||||
|
||||
redeemUsers: ->
|
||||
if not @usersToRedeem.size()
|
||||
redeemUsers: (usersToRedeem) ->
|
||||
if not usersToRedeem.size()
|
||||
@finishRedeemUsers()
|
||||
@hide()
|
||||
return
|
||||
|
||||
user = @usersToRedeem.first()
|
||||
user = usersToRedeem.first()
|
||||
prepaid = @prepaids.find((prepaid) -> prepaid.get('properties')?.endDate? and prepaid.openSpots() > 0)
|
||||
prepaid = @prepaids.find((prepaid) -> prepaid.openSpots() > 0) unless prepaid
|
||||
$.ajax({
|
||||
|
@ -102,19 +103,20 @@ module.exports = class ActivateLicensesModal extends ModalView
|
|||
url: _.result(prepaid, 'url') + '/redeemers'
|
||||
data: { userID: user.id }
|
||||
context: @
|
||||
success: ->
|
||||
@usersToRedeem.remove(user)
|
||||
pct = 100 * (@usersToRedeem.originalSize - @usersToRedeem.size() / @usersToRedeem.originalSize)
|
||||
@$('#progress-area .progress-bar').css('width', "#{pct.toFixed(1)}%")
|
||||
success: (prepaid) ->
|
||||
user.set('coursePrepaidID', prepaid._id)
|
||||
usersToRedeem.remove(user)
|
||||
# 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
|
||||
@redeemUsers()
|
||||
@redeemUsers(usersToRedeem)
|
||||
error: (jqxhr, textStatus, errorThrown) ->
|
||||
if jqxhr.status is 402
|
||||
message = arguments[2]
|
||||
else
|
||||
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: ->
|
||||
@trigger 'redeem-users'
|
||||
@trigger 'redeem-users', @state.get('selectedUsers')
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
RootView = require 'views/core/RootView'
|
||||
State = require 'models/State'
|
||||
template = require 'templates/courses/teacher-class-view'
|
||||
helper = require 'lib/coursesHelper'
|
||||
ClassroomSettingsModal = require 'views/courses/ClassroomSettingsModal'
|
||||
|
@ -12,6 +13,7 @@ Levels = require 'collections/Levels'
|
|||
LevelSessions = require 'collections/LevelSessions'
|
||||
User = require 'models/User'
|
||||
Users = require 'collections/Users'
|
||||
Course = require 'models/Course'
|
||||
Courses = require 'collections/Courses'
|
||||
CourseInstance = require 'models/CourseInstance'
|
||||
CourseInstances = require 'collections/CourseInstances'
|
||||
|
@ -21,6 +23,13 @@ module.exports = class TeacherClassView extends RootView
|
|||
template: template
|
||||
|
||||
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 .add-students-btn': 'onClickAddStudents'
|
||||
'click .sort-by-name': 'sortByName'
|
||||
|
@ -28,33 +37,59 @@ module.exports = class TeacherClassView extends RootView
|
|||
'click #copy-url-btn': 'copyURL'
|
||||
'click #copy-code-btn': 'copyCode'
|
||||
'click .remove-student-link': 'onClickRemoveStudentLink'
|
||||
'click .assign-student-button': 'onClickAssign'
|
||||
'click .enroll-student-button': 'onClickEnroll'
|
||||
'click .assign-to-selected-students': 'onClickBulkAssign'
|
||||
'click .enroll-selected-students': 'onClickBulkEnroll'
|
||||
'click .export-student-progress-btn': 'onClickExportStudentProgress'
|
||||
'click .select-all': 'onClickSelectAll'
|
||||
'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) ->
|
||||
super(options)
|
||||
@progressDotTemplate = require 'templates/courses/progress-dot'
|
||||
|
||||
@sortAttribute = 'name'
|
||||
@sortDirection = 1
|
||||
|
||||
@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'
|
||||
|
||||
@state = new State(@getInitialState())
|
||||
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.fetch()
|
||||
@supermodel.trackModel(@classroom)
|
||||
|
||||
|
||||
@students = new Users()
|
||||
@listenTo @classroom, 'sync', ->
|
||||
@students = new Users()
|
||||
jqxhrs = @students.fetchForClassroom(@classroom, removeDeleted: true)
|
||||
if jqxhrs.length > 0
|
||||
@supermodel.trackCollection(@students)
|
||||
@listenTo @students, 'sync', @sortByName
|
||||
@listenTo @students, 'sort', @renderSelectors.bind(@, '.students-table', '.student-levels-table')
|
||||
|
||||
|
||||
@classroom.sessions = new LevelSessions()
|
||||
requests = @classroom.sessions.fetchForAllClassroomMembers(@classroom)
|
||||
@supermodel.trackRequests(requests)
|
||||
|
@ -66,47 +101,119 @@ module.exports = class TeacherClassView extends RootView
|
|||
@courseInstances = new CourseInstances()
|
||||
@courseInstances.fetchForClassroom(classroomID)
|
||||
@supermodel.trackCollection(@courseInstances)
|
||||
|
||||
|
||||
@levels = new Levels()
|
||||
@levels.fetchForClassroom(classroomID, {data: {project: 'original,concepts'}})
|
||||
@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: ->
|
||||
@removeDeletedStudents()
|
||||
|
||||
@classCode = @classroom.get('codeCamel') or @classroom.get('code')
|
||||
@joinURL = document.location.origin + "/courses?_cc=" + @classCode
|
||||
|
||||
@earliestIncompleteLevel = helper.calculateEarliestIncomplete(@classroom, @courses, @courseInstances, @students)
|
||||
@latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, @students)
|
||||
@removeDeletedStudents() # TODO: Move this to mediator listeners? For both classroom and students?
|
||||
@calculateProgressAndLevels()
|
||||
super()
|
||||
|
||||
afterRender: ->
|
||||
super(arguments...)
|
||||
$('.progress-dot').each (i, el) ->
|
||||
dot = $(el)
|
||||
dot.tooltip({
|
||||
html: true
|
||||
container: dot
|
||||
}).delegate '.tooltip', 'mousemove', ->
|
||||
dot.tooltip('hide')
|
||||
|
||||
calculateProgressAndLevels: ->
|
||||
return unless @supermodel.progress is 1
|
||||
# TODO: How to structure this in @state?
|
||||
for student in @students.models
|
||||
# TODO: this is a weird hack
|
||||
studentsStub = new Users([ student ])
|
||||
student.latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, studentsStub)
|
||||
|
||||
|
||||
earliestIncompleteLevel = helper.calculateEarliestIncomplete(@classroom, @courses, @courseInstances, @students)
|
||||
latestCompleteLevel = helper.calculateLatestComplete(@classroom, @courses, @courseInstances, @students)
|
||||
|
||||
classroomsStub = new Classrooms([ @classroom ])
|
||||
@progressData = helper.calculateAllProgress(classroomsStub, @courses, @courseInstances, @students)
|
||||
# @conceptData = helper.calculateConceptsCovered(classroomsStub, @courses, @campaigns, @courseInstances, @students)
|
||||
|
||||
@selectedCourse = @courses.first()
|
||||
super()
|
||||
|
||||
progressData = helper.calculateAllProgress(classroomsStub, @courses, @courseInstances, @students)
|
||||
# conceptData: helper.calculateConceptsCovered(classroomsStub, @courses, @campaigns, @courseInstances, @students)
|
||||
|
||||
@state.set {
|
||||
earliestIncompleteLevel
|
||||
latestCompleteLevel
|
||||
progressData
|
||||
classStats: @calculateClassStats()
|
||||
}
|
||||
|
||||
copyCode: ->
|
||||
@$('#join-code-input').val(@classCode).select()
|
||||
@$('#join-code-input').val(@state.get('classCode')).select()
|
||||
@tryCopy()
|
||||
|
||||
copyURL: ->
|
||||
@$('#join-url-input').val(@joinURL).select()
|
||||
@$('#join-url-input').val(@state.get('joinURL')).select()
|
||||
@tryCopy()
|
||||
|
||||
tryCopy: ->
|
||||
try
|
||||
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
|
||||
message = 'Oops, unable to copy'
|
||||
noty text: message, layout: 'topCenter', type: 'error', killer: false
|
||||
|
||||
|
||||
onClickUnarchive: ->
|
||||
@classroom.save { archived: false }
|
||||
|
||||
onClickEditClassroom: (e) ->
|
||||
classroom = @classroom
|
||||
modal = new ClassroomSettingsModal({ classroom: classroom })
|
||||
|
@ -125,7 +232,6 @@ module.exports = class TeacherClassView extends RootView
|
|||
|
||||
onStudentRemoved: (e) ->
|
||||
@students.remove(e.user)
|
||||
@render()
|
||||
application.tracker?.trackEvent 'Classroom removed student', category: 'Courses', classroomID: @classroom.id, userID: e.user.id
|
||||
|
||||
onClickAddStudents: (e) =>
|
||||
|
@ -134,32 +240,33 @@ module.exports = class TeacherClassView extends RootView
|
|||
@listenToOnce modal, 'hide', @render
|
||||
|
||||
removeDeletedStudents: () ->
|
||||
return unless @classroom.loaded and @students.loaded
|
||||
_.remove(@classroom.get('members'), (memberID) =>
|
||||
not @students.get(memberID) or @students.get(memberID)?.get('deleted')
|
||||
)
|
||||
true
|
||||
|
||||
sortByName: (e) ->
|
||||
if @sortValue is 'name'
|
||||
@sortDirection = -@sortDirection
|
||||
if @state.get('sortValue') is 'name'
|
||||
@state.set('sortDirection', -@state.get('sortDirection'))
|
||||
else
|
||||
@sortValue = 'name'
|
||||
@sortDirection = 1
|
||||
|
||||
dir = @sortDirection
|
||||
@state.set('sortValue', 'name')
|
||||
@state.set('sortDirection', 1)
|
||||
|
||||
dir = @state.get('sortDirection')
|
||||
@students.comparator = (student1, student2) ->
|
||||
return (if student1.broadName().toLowerCase() < student2.broadName().toLowerCase() then -dir else dir)
|
||||
@students.sort()
|
||||
|
||||
sortByProgress: (e) ->
|
||||
if @sortValue is 'progress'
|
||||
@sortDirection = -@sortDirection
|
||||
if @state.get('sortValue') is 'progress'
|
||||
@state.set('sortDirection', -@state.get('sortDirection'))
|
||||
else
|
||||
@sortValue = 'progress'
|
||||
@sortDirection = 1
|
||||
|
||||
dir = @sortDirection
|
||||
|
||||
@state.set('sortValue', 'progress')
|
||||
@state.set('sortDirection', 1)
|
||||
|
||||
dir = @state.get('sortDirection')
|
||||
|
||||
@students.comparator = (student) ->
|
||||
#TODO: I would like for this to be in the Level model,
|
||||
# but it doesn't know about its own courseNumber
|
||||
|
@ -179,19 +286,24 @@ module.exports = class TeacherClassView extends RootView
|
|||
userID = $(e.currentTarget).data('user-id')
|
||||
user = @students.get(userID)
|
||||
selectedUsers = new Users([user])
|
||||
modal = new ActivateLicensesModal { @classroom, selectedUsers, users: @students }
|
||||
@openModalView(modal)
|
||||
modal.once 'redeem-users', -> document.location.reload()
|
||||
application.tracker?.trackEvent 'Classroom started enroll students', category: 'Courses'
|
||||
|
||||
@enrollStudents(selectedUsers)
|
||||
|
||||
onClickBulkEnroll: ->
|
||||
courseID = @$('.bulk-course-select').val()
|
||||
courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id })
|
||||
userIDs = @getSelectedStudentIDs().toArray()
|
||||
selectedUsers = new Users(@students.get(userID) for userID in userIDs)
|
||||
@enrollStudents(selectedUsers)
|
||||
|
||||
enrollStudents: (selectedUsers) ->
|
||||
modal = new ActivateLicensesModal { @classroom, selectedUsers, users: @students }
|
||||
@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'
|
||||
|
||||
onClickExportStudentProgress: ->
|
||||
|
@ -201,10 +313,10 @@ module.exports = class TeacherClassView extends RootView
|
|||
concepts = []
|
||||
for course, index in @courses.models
|
||||
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
|
||||
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 = _.union(_.flatten(concepts))
|
||||
conceptsString = _.map(concepts, (c) -> $.i18n.t("concepts." + c)).join(', ')
|
||||
|
@ -217,27 +329,37 @@ module.exports = class TeacherClassView extends RootView
|
|||
encodedUri = encodeURI(csvContent)
|
||||
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: ->
|
||||
courseID = @$('.bulk-course-select').val()
|
||||
courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id })
|
||||
selectedIDs = @getSelectedStudentIDs()
|
||||
members = selectedIDs.filter((index, userID) =>
|
||||
user = @students.get(userID)
|
||||
user.isEnrolled()
|
||||
).toArray()
|
||||
|
||||
@assigningToUnenrolled = _.any selectedIDs, (userID) =>
|
||||
|
||||
assigningToUnenrolled = _.any selectedIDs, (userID) =>
|
||||
not @students.get(userID).isEnrolled()
|
||||
|
||||
@$('.cant-assign-to-unenrolled').toggleClass('visible', @assigningToUnenrolled)
|
||||
|
||||
@assigningToNobody = selectedIDs.length is 0
|
||||
@$('.no-students-selected').toggleClass('visible', @assigningToNobody)
|
||||
|
||||
|
||||
assigningToNobody = selectedIDs.length is 0
|
||||
|
||||
@state.set errors: { assigningToNobody, assigningToUnenrolled }
|
||||
|
||||
@assignCourse courseID, members
|
||||
|
||||
# TODO: Move this to the model. Use promises/callbacks?
|
||||
assignCourse: (courseID, members) ->
|
||||
courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id })
|
||||
if courseInstance
|
||||
courseInstance.addMembers members, {
|
||||
success: @onBulkAssignSuccess
|
||||
}
|
||||
courseInstance.addMembers members
|
||||
else
|
||||
courseInstance = new CourseInstance {
|
||||
courseID,
|
||||
|
@ -247,17 +369,11 @@ module.exports = class TeacherClassView extends RootView
|
|||
}
|
||||
@courseInstances.add(courseInstance)
|
||||
courseInstance.save {}, {
|
||||
success: =>
|
||||
courseInstance.addMembers members, {
|
||||
success: @onBulkAssignSuccess
|
||||
}
|
||||
success: ->
|
||||
courseInstance.addMembers members
|
||||
}
|
||||
null
|
||||
|
||||
onBulkAssignSuccess: =>
|
||||
@render() unless @destroyed
|
||||
noty text: $.i18n.t('teacher.assigned'), layout: 'center', type: 'information', killer: true, timeout: 5000
|
||||
|
||||
|
||||
onClickSelectAll: (e) ->
|
||||
e.preventDefault()
|
||||
checkboxes = @$('.student-checkbox input')
|
||||
|
@ -278,11 +394,8 @@ module.exports = class TeacherClassView extends RootView
|
|||
checkboxes = @$('.student-checkbox input')
|
||||
@$('.select-all input').prop('checked', _.all(checkboxes, 'checked'))
|
||||
|
||||
onChangeCourseSelect: (e) ->
|
||||
@selectedCourse = @courses.get($(e.currentTarget).val())
|
||||
@renderSelectors('.render-on-course-sync')
|
||||
|
||||
classStats: ->
|
||||
calculateClassStats: ->
|
||||
return {} unless @classroom.sessions?.loaded and @students.loaded
|
||||
stats = {}
|
||||
|
||||
playtime = 0
|
||||
|
@ -301,4 +414,5 @@ module.exports = class TeacherClassView extends RootView
|
|||
|
||||
enrolledUsers = @students.filter (user) -> user.get('coursePrepaidID')
|
||||
stats.enrolledUsers = _.size(enrolledUsers)
|
||||
|
||||
return stats
|
||||
|
|
|
@ -44,7 +44,7 @@ module.exports = class TeacherClassesView extends RootView
|
|||
@courseInstances = new CourseInstances()
|
||||
@courseInstances.fetchByOwner(me.id)
|
||||
@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
|
||||
|
||||
|
|
|
@ -85,7 +85,7 @@ module.exports =
|
|||
members = classroom.get('members') or []
|
||||
members = members.slice(memberSkip, memberSkip + memberLimit)
|
||||
dbqs = []
|
||||
select = 'state.complete level creator playtime'
|
||||
select = 'state.complete level creator playtime changed dateFirstCompleted'
|
||||
for member in members
|
||||
dbqs.push(LevelSession.find({creator: member.toHexString()}).select(select).exec())
|
||||
results = yield dbqs
|
||||
|
|
|
@ -45,7 +45,8 @@ LevelSessionSchema.post 'init', (doc) ->
|
|||
LevelSessionSchema.pre 'save', (next) ->
|
||||
User = require './User' # Avoid mutual inclusion cycles
|
||||
Level = require './Level'
|
||||
@set('changed', new Date())
|
||||
now = new Date()
|
||||
@set('changed', now)
|
||||
|
||||
id = @get('id')
|
||||
initd = @previousStateInfo?
|
||||
|
@ -55,6 +56,7 @@ LevelSessionSchema.pre 'save', (next) ->
|
|||
|
||||
# Newly completed level
|
||||
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) ->
|
||||
log.error err if err?
|
||||
update = $inc: {'stats.gamesCompleted': 1}
|
||||
|
|
Loading…
Reference in a new issue