From 8223122a6b8a9786f3c35f1d47bac80941a4b3ef Mon Sep 17 00:00:00 2001 From: phoenixeliot Date: Tue, 19 Apr 2016 13:44:48 -0700 Subject: [PATCH] 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 --- app/lib/coursesHelper.coffee | 22 +- app/locale/en.coffee | 6 + app/models/CourseInstance.coffee | 4 +- app/models/State.coffee | 5 + app/schemas/models/level_session.coffee | 4 + app/styles/about.sass | 4 +- .../courses/activate-licenses-modal.sass | 9 + app/styles/courses/teacher-class-view.sass | 27 +- app/styles/courses/teacher-classes-view.sass | 2 +- app/styles/courses/teacher-dashboard-nav.sass | 2 +- app/styles/new-home-view.sass | 2 +- app/styles/style-flat-variables.sass | 7 + app/styles/style-flat.sass | 23 +- .../courses/activate-licenses-modal.jade | 64 +++-- app/templates/courses/teacher-class-view.jade | 153 ++++++---- ...rogress-dot-all-students-single-level.jade | 21 ++ .../progress-dot-single-student-course.jade | 18 ++ .../progress-dot-single-student-level.jade | 27 ++ .../hovers/progress-dot-whole-course.jade} | 0 .../teachers/request-quote-view.jade | 4 + app/views/core/CocoView.coffee | 3 +- .../courses/ActivateLicensesModal.coffee | 106 +++---- app/views/courses/TeacherClassView.coffee | 270 +++++++++++++----- app/views/courses/TeacherClassesView.coffee | 2 +- server/middleware/classrooms.coffee | 2 +- server/models/LevelSession.coffee | 4 +- 26 files changed, 564 insertions(+), 227 deletions(-) create mode 100644 app/models/State.coffee create mode 100644 app/styles/style-flat-variables.sass create mode 100644 app/templates/teachers/hovers/progress-dot-all-students-single-level.jade create mode 100644 app/templates/teachers/hovers/progress-dot-single-student-course.jade create mode 100644 app/templates/teachers/hovers/progress-dot-single-student-level.jade rename app/templates/{courses/progress-dot.jade => teachers/hovers/progress-dot-whole-course.jade} (100%) diff --git a/app/lib/coursesHelper.coffee b/app/lib/coursesHelper.coffee index cc7a56274..fd9a5b0e3 100644 --- a/app/lib/coursesHelper.coffee +++ b/app/lib/coursesHelper.coffee @@ -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 diff --git a/app/locale/en.coffee b/app/locale/en.coffee index 66010434f..898133727 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -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" diff --git a/app/models/CourseInstance.coffee b/app/models/CourseInstance.coffee index 74fb83b86..f072221d8 100644 --- a/app/models/CourseInstance.coffee +++ b/app/models/CourseInstance.coffee @@ -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) diff --git a/app/models/State.coffee b/app/models/State.coffee new file mode 100644 index 000000000..c63a7d518 --- /dev/null +++ b/app/models/State.coffee @@ -0,0 +1,5 @@ +CocoModel = require './CocoModel' +schema = require 'schemas/models/poll.schema' + +module.exports = class State extends CocoModel + @className: 'State' diff --git a/app/schemas/models/level_session.coffee b/app/schemas/models/level_session.coffee index 61bf5c594..c8b63902a 100644 --- a/app/schemas/models/level_session.coffee +++ b/app/schemas/models/level_session.coffee @@ -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 diff --git a/app/styles/about.sass b/app/styles/about.sass index d08d2dcbc..16764e8de 100644 --- a/app/styles/about.sass +++ b/app/styles/about.sass @@ -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 \ No newline at end of file + margin: -55px 0 0 0 diff --git a/app/styles/courses/activate-licenses-modal.sass b/app/styles/courses/activate-licenses-modal.sass index 625734dfe..a887f3b42 100644 --- a/app/styles/courses/activate-licenses-modal.sass +++ b/app/styles/courses/activate-licenses-modal.sass @@ -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 diff --git a/app/styles/courses/teacher-class-view.sass b/app/styles/courses/teacher-class-view.sass index ef61da089..4d83ab4c0 100644 --- a/app/styles/courses/teacher-class-view.sass +++ b/app/styles/courses/teacher-class-view.sass @@ -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 \ No newline at end of file + margin-top: 10px diff --git a/app/styles/courses/teacher-classes-view.sass b/app/styles/courses/teacher-classes-view.sass index 82ce88e39..7f439d895 100644 --- a/app/styles/courses/teacher-classes-view.sass +++ b/app/styles/courses/teacher-classes-view.sass @@ -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 diff --git a/app/styles/courses/teacher-dashboard-nav.sass b/app/styles/courses/teacher-dashboard-nav.sass index 1fac6f297..44fa7bc7f 100644 --- a/app/styles/courses/teacher-dashboard-nav.sass +++ b/app/styles/courses/teacher-dashboard-nav.sass @@ -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 diff --git a/app/styles/new-home-view.sass b/app/styles/new-home-view.sass index cad40f085..4342c3db5 100644 --- a/app/styles/new-home-view.sass +++ b/app/styles/new-home-view.sass @@ -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 diff --git a/app/styles/style-flat-variables.sass b/app/styles/style-flat-variables.sass new file mode 100644 index 000000000..340eeea72 --- /dev/null +++ b/app/styles/style-flat-variables.sass @@ -0,0 +1,7 @@ +$headline-font: 'Arvo', serif +$body-font: 'Open Sans', sans-serif + +$burgandy: #7D0101 +$gold: #F2BE19 +$navy: #0E4C60 +$forest: #20572B diff --git a/app/styles/style-flat.sass b/app/styles/style-flat.sass index 1c9c6e080..376010aee 100644 --- a/app/styles/style-flat.sass +++ b/app/styles/style-flat.sass @@ -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 diff --git a/app/templates/courses/activate-licenses-modal.jade b/app/templates/courses/activate-licenses-modal.jade index 591ef66f9..ed1ca6d12 100644 --- a/app/templates/courses/activate-licenses-modal.jade +++ b/app/templates/courses/activate-licenses-modal.jade @@ -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") diff --git a/app/templates/courses/teacher-class-view.jade b/app/templates/courses/teacher-class-view.jade index 5016833b3..cfd455d5f 100644 --- a/app/templates/courses/teacher-class-view.jade +++ b/app/templates/courses/teacher-class-view.jade @@ -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') \ No newline at end of file + span(data-i18n='teacher.enroll_selected_students') diff --git a/app/templates/teachers/hovers/progress-dot-all-students-single-level.jade b/app/templates/teachers/hovers/progress-dot-all-students-single-level.jade new file mode 100644 index 000000000..2a04ae2f5 --- /dev/null +++ b/app/templates/teachers/hovers/progress-dot-all-students-single-level.jade @@ -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 diff --git a/app/templates/teachers/hovers/progress-dot-single-student-course.jade b/app/templates/teachers/hovers/progress-dot-single-student-course.jade new file mode 100644 index 000000000..9b289f225 --- /dev/null +++ b/app/templates/teachers/hovers/progress-dot-single-student-course.jade @@ -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 diff --git a/app/templates/teachers/hovers/progress-dot-single-student-level.jade b/app/templates/teachers/hovers/progress-dot-single-student-level.jade new file mode 100644 index 000000000..3922be2ed --- /dev/null +++ b/app/templates/teachers/hovers/progress-dot-single-student-level.jade @@ -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 diff --git a/app/templates/courses/progress-dot.jade b/app/templates/teachers/hovers/progress-dot-whole-course.jade similarity index 100% rename from app/templates/courses/progress-dot.jade rename to app/templates/teachers/hovers/progress-dot-whole-course.jade diff --git a/app/templates/teachers/request-quote-view.jade b/app/templates/teachers/request-quote-view.jade index 255b67e67..f92a53fa3 100644 --- a/app/templates/teachers/request-quote-view.jade +++ b/app/templates/teachers/request-quote-view.jade @@ -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") diff --git a/app/views/core/CocoView.coffee b/app/views/core/CocoView.coffee index 6856acdb6..97eedca8a 100644 --- a/app/views/core/CocoView.coffee +++ b/app/views/core/CocoView.coffee @@ -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 diff --git a/app/views/courses/ActivateLicensesModal.coffee b/app/views/courses/ActivateLicensesModal.coffee index d847c4a32..709a1e2dc 100644 --- a/app/views/courses/ActivateLicensesModal.coffee +++ b/app/views/courses/ActivateLicensesModal.coffee @@ -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') diff --git a/app/views/courses/TeacherClassView.coffee b/app/views/courses/TeacherClassView.coffee index 071de926d..5302d0ee2 100644 --- a/app/views/courses/TeacherClassView.coffee +++ b/app/views/courses/TeacherClassView.coffee @@ -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 diff --git a/app/views/courses/TeacherClassesView.coffee b/app/views/courses/TeacherClassesView.coffee index e969d12e0..5a5f4e904 100644 --- a/app/views/courses/TeacherClassesView.coffee +++ b/app/views/courses/TeacherClassesView.coffee @@ -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 diff --git a/server/middleware/classrooms.coffee b/server/middleware/classrooms.coffee index 3e8980863..4f5a3f8c9 100644 --- a/server/middleware/classrooms.coffee +++ b/server/middleware/classrooms.coffee @@ -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 diff --git a/server/models/LevelSession.coffee b/server/models/LevelSession.coffee index 06846e3cc..29ecccda4 100644 --- a/server/models/LevelSession.coffee +++ b/server/models/LevelSession.coffee @@ -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}