From ef0547f72aea401a743738371d7e0925ada31f14 Mon Sep 17 00:00:00 2001 From: Scott Erickson Date: Tue, 23 Aug 2016 10:43:31 -0700 Subject: [PATCH] Simplify applying licenses In TeacherClassView, when a teacher assigns a paid course to any unenrolled student, the view automatically enrolls those students, rather than requiring the teacher to enroll those students manually first. Update copy throughout. Also add back (smaller) padding to progress dots in TeacherClassView. --- .../pages/courses/how_to_apply_licenses.png | Bin 0 -> 24693 bytes app/collections/CocoCollection.coffee | 4 +- app/core/CocoClass.coffee | 2 + app/locale/en.coffee | 29 ++-- app/models/CocoModel.coffee | 2 + app/models/CourseInstance.coffee | 3 +- app/models/Prepaid.coffee | 1 + app/styles/courses/teacher-class-view.sass | 3 +- app/styles/teachers/how-to-enroll-modal.sass | 4 + .../courses/activate-licenses-modal.jade | 12 +- .../courses/courses-not-assigned-modal.jade | 13 ++ app/templates/courses/enrollments-view.jade | 4 +- app/templates/courses/teacher-class-view.jade | 44 ++---- .../teachers/how-to-enroll-modal.jade | 12 +- app/views/core/CocoView.coffee | 2 + .../courses/CoursesNotAssignedModal.coffee | 9 ++ app/views/courses/TeacherClassView.coffee | 126 ++++++++++++----- .../teachers/TeacherClassView.spec.coffee | 133 ++++++++++++++++-- 18 files changed, 293 insertions(+), 110 deletions(-) create mode 100755 app/assets/images/pages/courses/how_to_apply_licenses.png create mode 100644 app/styles/teachers/how-to-enroll-modal.sass create mode 100644 app/templates/courses/courses-not-assigned-modal.jade create mode 100644 app/views/courses/CoursesNotAssignedModal.coffee diff --git a/app/assets/images/pages/courses/how_to_apply_licenses.png b/app/assets/images/pages/courses/how_to_apply_licenses.png new file mode 100755 index 0000000000000000000000000000000000000000..7af29f522990cdada83b930e4533918d505d1b3f GIT binary patch literal 24693 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 Nih`zmt*mv_{{Z-Di3$J! literal 0 HcmV?d00001 diff --git a/app/collections/CocoCollection.coffee b/app/collections/CocoCollection.coffee index 18d1d9ddd..fce2acc2f 100644 --- a/app/collections/CocoCollection.coffee +++ b/app/collections/CocoCollection.coffee @@ -33,4 +33,6 @@ module.exports = class CocoCollection extends Backbone.Collection setProjection: (@project) -> - stringify: -> return JSON.stringify(@toJSON()) \ No newline at end of file + stringify: -> return JSON.stringify(@toJSON()) + + wait: (event) -> new Promise((resolve) => @once(event, resolve)) diff --git a/app/core/CocoClass.coffee b/app/core/CocoClass.coffee index c3fb2da8b..9b1adf41e 100644 --- a/app/core/CocoClass.coffee +++ b/app/core/CocoClass.coffee @@ -84,3 +84,5 @@ module.exports = class CocoClass playSound: (trigger, volume=1) -> Backbone.Mediator.publish 'audio-player:play-sound', trigger: trigger, volume: volume + + wait: (event) -> new Promise((resolve) => @once(event, resolve)) diff --git a/app/locale/en.coffee b/app/locale/en.coffee index f8abf1e2d..7604c5f77 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -1434,12 +1434,13 @@ earliest_incomplete: "Earliest incomplete level" latest_complete: "Latest completed level" enroll_student: "Enroll student" + apply_license: "Apply License" course_progress: "Course Progress" not_applicable: "N/A" edit: "edit" edit_2: "Edit" remove: "remove" - latest_completed: "Latest completed" + latest_completed: "Latest completed:" # {change} sort_by: "Sort by" progress: "Progress" completed: "Completed" @@ -1447,6 +1448,7 @@ click_to_view_progress: "click to view progress" no_progress: "No progress" select_course: "Select course to view" + students_not_assigned: "Students who have not been assigned {{courseName}}" course_overview: "Course Overview" copy_class_code: "Copy Class Code" class_code_blurb: "Students can join your class using this Class Code. No email address is required when creating a Student account with this Class Code." @@ -1454,16 +1456,24 @@ class_join_url_blurb: "You can also post this unique class URL to a shared webpage." add_students_manually: "Invite Students by Email" bulk_assign: "Bulk-assign" + assigned_msg_1: "{{numberAssigned}} students were assigned {{courseName}}." + assigned_msg_2: "{{numberEnrolled}} licenses were applied." + assigned_msg_3: "You now have {{remainingSpots}} available licenses remaining." + assign_course: "Assign Course" + not_assigned_modal_title: "Courses were not assigned" + not_assigned_modal_body_1: "You do not have enough licenses available to assign additional Courses to all {{selected}} selected students." + not_assigned_modal_body_2: "You only have {{totalSpotsAvailable}} licenses available ({{unenrolledStudents}} students did not have an active license)." + not_assigned_modal_body_3: "Please select fewer students, or reach out to {{email}} for assistance." assign_to_selected_students: "Assign to Selected Students" assigned: "Assigned" enroll_selected_students: "Enroll Selected Students" - cant_assign_to_unenrolled: "Course cannot be assigned to students who are not enrolled." no_students_selected: "No students were selected." guides_coming_soon: "Guides coming soon!" # Courses show_students_from: "Show students from" # Enroll students modal - enroll_the_following_students: "Enroll the following students" + apply_licenses_to_the_following_students: "Apply Licenses to the Following Students" + students_have_licenses: "The following students already have licenses applied:" all_students: "All Students" - enroll_students: "Enroll Students" + apply_licenses: "Apply Licenses" not_enough_enrollments: "Not enough licenses available." enrollments_blurb_1: "Students taking Computer Science" enrollments_blurb_2: "require a license to access the courses." @@ -1476,9 +1486,7 @@ purchased: "Purchased!" purchase_now: "Purchase Now" how_to_enroll: "How to Enroll Students" - how_to_enroll_blurb_1: "If a student is not enrolled yet, there will be an \"Enroll\" button next to their course progress in your class." - how_to_enroll_blurb_2: "To bulk-enroll multiple students, select them using the checkboxes on the left side of the classroom page and click the \"Enroll Selected Students\" button." - how_to_enroll_blurb_3: "Once a student is enrolled, they will have access to all of the course content." + how_to_apply_licenses: "How to Apply Licenses" bulk_pricing_blurb: "Purchasing for more than 25 students? Contact us to discuss next steps." total_unenrolled: "Total unenrolled" export_student_progress: "Export Student Progress (CSV)" @@ -1495,11 +1503,12 @@ end_date: "end date:" num_enrollments_needed: "Number of licenses needed:" get_enrollments_blurb: " We'll help you build a solution that meets the needs of your class, school or district." - enroll_request_sent_blurb1: "Thanks! Your request has been sent." - enroll_request_sent_blurb2: "Our classroom success team will be in touch shortly to help you find the best solution for your students' needs!" - enroll_request_sent_blurb3: "Please reach out to schools@codecombat.com if you have additional questions at this time." + how_to_apply_licenses_blurb_1: "When a teacher assigns a course to a student for the first time, we’ll automatically apply a license. Use the bulk-assign dropdown in your classroom to assign a course to selected students:" + how_to_apply_licenses_blurb_2: "Can I still apply a license without assigning a course?" + how_to_apply_licenses_blurb_3: "Yes — go to the License Status tab in your classroom and click \"Apply License\" to any student who does not have an active license." request_sent: "Request Sent!" enrollment_status: "Enrollment Status" + license_status: "License Status" status_expired: "Expired on {{date}}" status_not_enrolled: "Not Enrolled" status_enrolled: "Expires on {{date}}" diff --git a/app/models/CocoModel.coffee b/app/models/CocoModel.coffee index 8441d2bc0..0de5644c0 100644 --- a/app/models/CocoModel.coffee +++ b/app/models/CocoModel.coffee @@ -460,4 +460,6 @@ class CocoModel extends Backbone.Model stringify: -> return JSON.stringify(@toJSON()) + wait: (event) -> new Promise((resolve) => @once(event, resolve)) + module.exports = CocoModel diff --git a/app/models/CourseInstance.coffee b/app/models/CourseInstance.coffee index f072221d8..1a6cb39e9 100644 --- a/app/models/CourseInstance.coffee +++ b/app/models/CourseInstance.coffee @@ -36,11 +36,12 @@ module.exports = class CourseInstance extends CocoModel @trigger 'add-members', { userIDs } } _.extend options, opts - @fetch(options) + jqxhr = @fetch(options) if me.id in userIDs unless me.get('courseInstances') me.set('courseInstances', []) me.get('courseInstances').push(@id) + return jqxhr removeMember: (userID, opts) -> options = { diff --git a/app/models/Prepaid.coffee b/app/models/Prepaid.coffee index 360a21f50..b3e8d0f91 100644 --- a/app/models/Prepaid.coffee +++ b/app/models/Prepaid.coffee @@ -19,6 +19,7 @@ module.exports = class Prepaid extends CocoModel maxRedeemers = @get('maxRedeemers') if _.isString(maxRedeemers) @set 'maxRedeemers', parseInt(maxRedeemers) + super(arguments...) status: -> endDate = @get('endDate') diff --git a/app/styles/courses/teacher-class-view.sass b/app/styles/courses/teacher-class-view.sass index 7a3a0ce5b..186e66c9f 100644 --- a/app/styles/courses/teacher-class-view.sass +++ b/app/styles/courses/teacher-class-view.sass @@ -260,6 +260,7 @@ min-width: 34px height: 34px border-radius: 16px + padding: 0 5px // margin-top: 23px // margin-bottom: 23px background: $gray-light @@ -337,7 +338,7 @@ top: 8px right: 5px - #enrollment-status-table + #license-status-table // These column widths are just to keep the cells from resizing on search .checkbox-col width: 75px diff --git a/app/styles/teachers/how-to-enroll-modal.sass b/app/styles/teachers/how-to-enroll-modal.sass new file mode 100644 index 000000000..d56be171e --- /dev/null +++ b/app/styles/teachers/how-to-enroll-modal.sass @@ -0,0 +1,4 @@ +#how-to-enroll-modal + img + width: 500px + margin: 0 auto diff --git a/app/templates/courses/activate-licenses-modal.jade b/app/templates/courses/activate-licenses-modal.jade index 2509cbf20..78befca4a 100644 --- a/app/templates/courses/activate-licenses-modal.jade +++ b/app/templates/courses/activate-licenses-modal.jade @@ -3,7 +3,7 @@ extends /templates/core/modal-base-flat block modal-header-content .clearfix .text-center - h1(data-i18n="teacher.enroll_students") + h1(data-i18n="teacher.apply_licenses") h2(data-i18n="courses.grants_lifetime_access") block modal-body-content @@ -26,7 +26,7 @@ block modal-body-content option(selected=(!view.classroom), value='' data-i18n='teacher.all_students') form.form.m-t-3 - span(data-i18n="teacher.enroll_the_following_students") + span(data-i18n="teacher.apply_licenses_to_the_following_students") span : .well.form-group - var enrolledUsers = view.users.filter(function(user){ return user.isEnrolled() }) @@ -39,8 +39,7 @@ block modal-body-content span.spr= user.broadName() if enrolledUsers.length > 0 .small-details.m-t-3 - span(data-i18n='TODO') - | The following students are already enrolled: + span(data-i18n='teacher.students_have_licenses') for user in enrolledUsers - var selected = Boolean(paid || state.get('selectedUsers').get(user.id)) .checkbox @@ -62,12 +61,11 @@ block modal-body-content p button#activate-licenses-btn.btn.btn-lg.btn-primary(type="submit" class=(tooManySelected || noneSelected ? 'disabled' : '')) - span.spr(data-i18n="courses.enroll") - | ( + span(data-i18n="teacher.apply_licenses") + | ( span#total-selected-span = numToEnroll | ) - span.spl(data-i18n="courses.students1") p a#get-more-licenses-btn.btn.btn-lg.btn-primary-alt(href="/teachers/licenses", data-i18n="courses.get_enrollments") diff --git a/app/templates/courses/courses-not-assigned-modal.jade b/app/templates/courses/courses-not-assigned-modal.jade new file mode 100644 index 000000000..69cd62bf6 --- /dev/null +++ b/app/templates/courses/courses-not-assigned-modal.jade @@ -0,0 +1,13 @@ +extends /templates/core/modal-base-flat + +block modal-header-content + h1(data-i18n="teacher.not_assigned_modal_title") + +block modal-body-content + p= translate("teacher.not_assigned_modal_body_1").replace('{{selected}}', view.selected) + p= translate("teacher.not_assigned_modal_body_2").replace('{{totalSpotsAvailable}}', view.totalSpotsAvailable).replace('{{unenrolledStudents}}', view.unenrolledStudents) + p!= translate("teacher.not_assigned_modal_body_3").replace('{{email}}', "support@codecombat.com") + + +block modal-footer-content + button.btn.btn-primary(data-dismiss="modal", data-i18n="modal.okay") diff --git a/app/templates/courses/enrollments-view.jade b/app/templates/courses/enrollments-view.jade index 0ec9f35fb..3cdc6be55 100644 --- a/app/templates/courses/enrollments-view.jade +++ b/app/templates/courses/enrollments-view.jade @@ -36,7 +36,7 @@ block content .pull-right span.glyphicon.glyphicon-question-sign =' ' - a#how-to-enroll-link(data-i18n="teacher.how_to_enroll") + a#how-to-enroll-link(data-i18n="teacher.how_to_apply_licenses") h3(data-i18n='teacher.enrollments') h4#enrollments-blurb span(data-i18n='teacher.enrollments_blurb_1') @@ -131,4 +131,4 @@ mixin enrollmentStats .text-center button#enroll-students-btn.btn.btn-lg.btn-navy - span(data-i18n='teacher.enroll_students') + span(data-i18n='teacher.apply_licenses') diff --git a/app/templates/courses/teacher-class-view.jade b/app/templates/courses/teacher-class-view.jade index 791cde44e..f8dfce36b 100644 --- a/app/templates/courses/teacher-class-view.jade +++ b/app/templates/courses/teacher-class-view.jade @@ -119,9 +119,9 @@ block content a.course-progress-tab-btn(href='#course-progress-tab') .small-details.text-center(data-i18n='teacher.course_progress') .tab-spacer - li(class=(activeTab === "#enrollment-status-tab" ? 'active' : '')) - a.course-progress-tab-btn(href='#enrollment-status-tab') - .small-details.text-center(data-i18n='teacher.enrollment_status') + li(class=(activeTab === "#license-status-tab" ? 'active' : '')) + a.course-progress-tab-btn(href='#license-status-tab') + .small-details.text-center(data-i18n='teacher.license_status') .tab-filler .tab-content @@ -129,7 +129,7 @@ block content +studentsTab else if activeTab === '#course-progress-tab' +courseProgressTab - else if activeTab === '#enrollment-status-tab' + else if activeTab === '#license-status-tab' +enrollmentStatusTab else @@ -215,7 +215,6 @@ mixin studentRow(student) div i span(data-i18n='teacher.latest_completed') - span : div +longLevelName(student.latestCompleteLevel) td @@ -231,8 +230,6 @@ mixin studentRow(student) //- - var level = ??? - var label = courseLabelsArray[index]; +studentCourseProgressDot(progress, levelsTotal, level, label) - unless student.isEnrolled() - +enrollStudentButton(student) //- td //- span.view-class-arrow.glyphicon.glyphicon-chevron-right td @@ -244,10 +241,6 @@ mixin studentRow(student) div.glyphicon.glyphicon-remove div(data-i18n='teacher.remove') -mixin enrollStudentButton(student) - a.enroll-student-button.btn.btn-lg.btn-primary(data-classroom-id=view.classroom.id data-user-id=student.id data-event-action="Teachers Class Students Enroll Student") - span(data-i18n='teacher.enroll_student') - mixin courseProgressTab #course-progress-tab.m-t-3 if view.courses @@ -267,14 +260,11 @@ mixin courseProgressTab 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') + - var courseName = i18n(state.get('selectedCourse').attributes, 'name'); + span= translate('teacher.students_not_assigned').replace('{{courseName}}', courseName) for student in state.get('students').models unless _.contains(state.get('selectedCourse').members, student.id) .row.unassigned-student-row.alternating-background @@ -285,19 +275,11 @@ mixin courseProgressTab .col-sm-4 .latest-completed.truncate.small i.m-r-1 - span(data-i18n='TODO') - | Latest completed - | : + span(data-i18n='teacher.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 data-event-action="Teachers Class Course Enroll Student") - span(data-i18n='TODO') - | Enroll Student + .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='teacher.assign_course') mixin courseOverview - var course = state.get('selectedCourse') @@ -417,8 +399,6 @@ mixin bulkAssignControls .bulk-assign-controls.form-inline .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=state.get('errors').assigningToUnenrolled ? 'visible' : '') - span(data-i18n='teacher.cant_assign_to_unenrolled') span.small span(data-i18n='teacher.bulk_assign') span : @@ -429,8 +409,6 @@ mixin bulkAssignControls = i18n(course.attributes, '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') mixin enrollmentStatusTab // TODO: Have search input in all tabs @@ -441,7 +419,7 @@ mixin enrollmentStatusTab // input#student-search.form-control.m-l-1(type="search") // span.glyphicon.glyphicon-search.form-control-feedback - table.table#enrollment-status-table.table-condensed.m-t-3 + table.table#license-status-table.table-condensed.m-t-3 thead // Checkbox code works, but don't need it yet. //th.checkbox-col.select-all @@ -473,4 +451,4 @@ mixin enrollmentStatusTab strong(class= status === 'expired' ? 'text-danger' : '')= view.studentStatusString(student) td.enroll-col if status !== 'enrolled' - button.enroll-student-button.btn.btn-navy(data-i18n="teacher.enroll_student", data-user-id=student.id, data-event-action="Teachers Class Enrollment Enroll Student") + button.enroll-student-button.btn.btn-navy(data-i18n="teacher.apply_license", data-user-id=student.id, data-event-action="Teachers Class Enrollment Enroll Student") diff --git a/app/templates/teachers/how-to-enroll-modal.jade b/app/templates/teachers/how-to-enroll-modal.jade index 819dc19e3..fe10cfaa0 100644 --- a/app/templates/teachers/how-to-enroll-modal.jade +++ b/app/templates/teachers/how-to-enroll-modal.jade @@ -2,11 +2,11 @@ extends /templates/core/modal-base-flat block modal-header-content .text-center - h3(data-i18n='teacher.how_to_enroll') + h3(data-i18n='teacher.how_to_apply_licenses') -block modal-body +block modal-body-content - ol - li.m-t-1(data-i18n='teacher.how_to_enroll_blurb_1') - li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_2') - li.m-t-2(data-i18n='teacher.how_to_enroll_blurb_3') + p(data-i18n='teacher.how_to_apply_licenses_blurb_1') + img.m-y-3(src="/images/pages/courses/how_to_apply_licenses.png") + h5(data-i18n='teacher.how_to_apply_licenses_blurb_2') + p(data-i18n='teacher.how_to_apply_licenses_blurb_3') diff --git a/app/views/core/CocoView.coffee b/app/views/core/CocoView.coffee index 2bfad5055..ddf793df0 100644 --- a/app/views/core/CocoView.coffee +++ b/app/views/core/CocoView.coffee @@ -504,6 +504,8 @@ module.exports = class CocoView extends Backbone.View message = 'Oops, unable to copy' noty text: message, layout: 'topCenter', type: 'error', killer: false + wait: (event) -> new Promise((resolve) => @once(event, resolve)) + mobileRELong = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i mobileREShort = /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i diff --git a/app/views/courses/CoursesNotAssignedModal.coffee b/app/views/courses/CoursesNotAssignedModal.coffee new file mode 100644 index 000000000..c20e527e6 --- /dev/null +++ b/app/views/courses/CoursesNotAssignedModal.coffee @@ -0,0 +1,9 @@ +ModalView = require 'views/core/ModalView' +template = require 'templates/courses/courses-not-assigned-modal' + +module.exports = class CoursesNotAssignedModal extends ModalView + id: 'courses-not-assigned-modal' + template: template + + initialize: (options) -> + _.assign(@, _.pick(options, 'selected', 'totalSpotsAvailable', 'unenrolledStudents')) diff --git a/app/views/courses/TeacherClassView.coffee b/app/views/courses/TeacherClassView.coffee index 8f8fd1aae..efff68cde 100644 --- a/app/views/courses/TeacherClassView.coffee +++ b/app/views/courses/TeacherClassView.coffee @@ -7,6 +7,7 @@ InviteToClassroomModal = require 'views/courses/InviteToClassroomModal' ActivateLicensesModal = require 'views/courses/ActivateLicensesModal' EditStudentModal = require 'views/teachers/EditStudentModal' RemoveStudentModal = require 'views/courses/RemoveStudentModal' +CoursesNotAssignedModal = require './CoursesNotAssignedModal' Campaigns = require 'collections/Campaigns' Classroom = require 'models/Classroom' @@ -19,6 +20,7 @@ Course = require 'models/Course' Courses = require 'collections/Courses' CourseInstance = require 'models/CourseInstance' CourseInstances = require 'collections/CourseInstances' +Prepaids = require 'collections/Prepaids' module.exports = class TeacherClassView extends RootView id: 'teacher-class-view' @@ -38,7 +40,6 @@ module.exports = class TeacherClassView extends RootView 'click .assign-student-button': 'onClickAssignStudentButton' 'click .enroll-student-button': 'onClickEnrollStudentButton' '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' @@ -55,7 +56,6 @@ module.exports = class TeacherClassView extends RootView joinURL: "" errors: assigningToNobody: false - assigningToUnenrolled: false selectedCourse: undefined checkboxStates: {} classStats: @@ -85,6 +85,10 @@ module.exports = class TeacherClassView extends RootView @onKeyPressStudentSearch = _.debounce(@onKeyPressStudentSearch, 200) @sortedCourses = [] + @prepaids = new Prepaids() + @prepaids.comparator = 'endDate' # use prepaids in order of expiration + @supermodel.trackRequest @prepaids.fetchByCreator(me.id) + @students = new Users() @listenTo @classroom, 'sync', -> jqxhrs = @students.fetchForClassroom(@classroom, removeDeleted: true) @@ -140,8 +144,6 @@ module.exports = class TeacherClassView extends RootView @state.set selectedCourse: @courses.first() unless @state.get('selectedCourse') @listenTo @courseInstances, 'sync change update', -> @setCourseMembers() - @listenTo @courseInstances, 'add-members', -> - noty text: $.i18n.t('teacher.assigned'), layout: 'center', type: 'information', killer: true, timeout: 5000 @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? @@ -173,7 +175,7 @@ module.exports = class TeacherClassView extends RootView @listenTo @courseInstances, 'sync change update', @debouncedRender @listenTo @state, 'sync change', -> if _.isEmpty(_.omit(@state.changed, 'searchTerm')) - @renderSelectors('#enrollment-status-table') + @renderSelectors('#license-status-table') else @debouncedRender() @listenTo @students, 'sort', @debouncedRender @@ -304,12 +306,6 @@ module.exports = class TeacherClassView extends RootView @enrollStudents(selectedUsers) window.tracker?.trackEvent $(e.currentTarget).data('event-action'), category: 'Teachers', classroomID: @classroom.id, userID: userID, ['Mixpanel'] - onClickBulkEnroll: -> - userIDs = @getSelectedStudentIDs() - selectedUsers = new Users(@students.get(userID) for userID in userIDs) - @enrollStudents(selectedUsers) - window.tracker?.trackEvent 'Teachers Class Students Enroll Selected', category: 'Teachers', classroomID: @classroom.id, ['Mixpanel'] - enrollStudents: (selectedUsers) -> modal = new ActivateLicensesModal { @classroom, selectedUsers, users: @students } @openModalView(modal) @@ -390,35 +386,95 @@ module.exports = class TeacherClassView extends RootView onClickBulkAssign: -> courseID = @$('.bulk-course-select').val() selectedIDs = @getSelectedStudentIDs() - members = selectedIDs.filter (userID) => - user = @students.get(userID) - user.isEnrolled() - assigningToUnenrolled = _.any selectedIDs, (userID) => - not @students.get(userID).isEnrolled() assigningToNobody = selectedIDs.length is 0 - @state.set errors: { assigningToNobody, assigningToUnenrolled } + @state.set errors: { assigningToNobody } return if assigningToNobody - @assignCourse courseID, members + @assignCourse courseID, selectedIDs window.tracker?.trackEvent 'Teachers Class Students Assign Selected', category: 'Teachers', classroomID: @classroom.id, courseID: courseID, ['Mixpanel'] - # TODO: Move this to the model. Use promises/callbacks? assignCourse: (courseID, members) -> - courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id }) - if courseInstance - courseInstance.addMembers members - else - courseInstance = new CourseInstance { - courseID, - classroomID: @classroom.id - ownerID: @classroom.get('ownerID') - aceConfig: {} - } - @courseInstances.add(courseInstance) - courseInstance.save {}, { - success: -> - courseInstance.addMembers members - } - null + courseInstance = null + numberEnrolled = 0 + remainingSpots = 0 + + return Promise.resolve() + .then => + courseInstance = @courseInstances.findWhere({ courseID, classroomID: @classroom.id }) + if not courseInstance + courseInstance = new CourseInstance { + courseID, + classroomID: @classroom.id + ownerID: @classroom.get('ownerID') + aceConfig: {} + } + courseInstance.notyErrors = false # handling manually + @courseInstances.add(courseInstance) + return courseInstance.save() + + .then => + availablePrepaids = @prepaids.filter((prepaid) -> prepaid.status() is 'available') + unenrolledStudents = _(members) + .map((userID) => @students.get(userID)) + .filter((user) => user.prepaidStatus() isnt 'enrolled') + .value() + totalSpotsAvailable = _.reduce(prepaid.openSpots() for prepaid in availablePrepaids, (val, total) -> val + total) or 0 + if totalSpotsAvailable < _.size(unenrolledStudents) + modal = new CoursesNotAssignedModal({ + selected: members.length + totalSpotsAvailable + unenrolledStudents: _.size(unenrolledStudents) + }) + @openModalView(modal) + error = new Error('Not enough licenses available') + error.handled = true + throw error + + numberEnrolled = _.size(unenrolledStudents) + remainingSpots = totalSpotsAvailable - numberEnrolled + + requests = [] + for prepaid in availablePrepaids + for i in _.range(prepaid.openSpots()) + break unless _.size(unenrolledStudents) > 0 + user = unenrolledStudents.shift() + requests.push(prepaid.redeem(user)) + + @trigger 'begin-redeem-for-assign-course' + return $.when(requests...) + + .then => + # refresh prepaids, since the racing multiple parallel redeem requests in the previous `then` probably did not + # end up returning the final result of all those requests together. + @prepaids.fetchByCreator(me.id) + + @trigger 'begin-assign-course' + if members.length + return courseInstance.addMembers(members) + + .then => + course = @courses.get(courseID) + lines = [ + $.i18n.t('teacher.assigned_msg_1') + .replace('{{numberAssigned}}', members.length) + .replace('{{courseName}}', course.get('name')) + ] + if numberEnrolled > 0 + lines.push( + $.i18n.t('teacher.assigned_msg_2') + .replace('{{numberEnrolled}}', numberEnrolled) + ) + lines.push( + $.i18n.t('teacher.assigned_msg_3') + .replace('{{remainingSpots}}', remainingSpots) + ) + noty text: lines.join('
'), layout: 'center', type: 'information', killer: true, timeout: 5000 + + .catch (e) => + # TODO: Use this handling for errors site-wide? + return if e.handled + throw e if e instanceof Error and application.testing + text = if e instanceof Error then 'Runtime error' else e.responseJSON?.message or e.message or $.i18n.t('loading_error.unknown') + noty { text, layout: 'center', type: 'error', killer: true, timeout: 5000 } onClickSelectAll: (e) -> e.preventDefault() diff --git a/test/app/views/teachers/TeacherClassView.spec.coffee b/test/app/views/teachers/TeacherClassView.spec.coffee index 13e946f15..c989fe341 100644 --- a/test/app/views/teachers/TeacherClassView.spec.coffee +++ b/test/app/views/teachers/TeacherClassView.spec.coffee @@ -7,6 +7,7 @@ Courses = require 'collections/Courses' Levels = require 'collections/Levels' LevelSessions = require 'collections/LevelSessions' CourseInstances = require 'collections/CourseInstances' +Prepaids = require 'collections/Prepaids' describe '/teachers/classes/:handle', -> @@ -30,13 +31,15 @@ describe 'TeacherClassView', -> factories.makeCourse({name: 'Beta Course', releasePhase: 'beta'}), ]) @releasedCourses = new Courses(@courses.where({ releasePhase: 'released' })) - available = factories.makePrepaid() + @available1 = factories.makePrepaid({maxRedeemers: 1}) + @available2 = factories.makePrepaid({maxRedeemers: 1}) expired = factories.makePrepaid({endDate: moment().subtract(1, 'day').toISOString()}) + @prepaids = new Prepaids([@available1, @available2, expired]) @students = new Users([ factories.makeUser({name: 'Abner'}) factories.makeUser({name: 'Abigail'}) - factories.makeUser({name: 'Abby'}, {prepaid: available}) - factories.makeUser({name: 'Ben'}, {prepaid: available}) + factories.makeUser({name: 'Abby'}, {prepaid: @available1}) + factories.makeUser({name: 'Ben'}, {prepaid: @available1}) factories.makeUser({name: 'Ned'}, {prepaid: expired}) factories.makeUser({name: 'Ebner'}, {prepaid: expired}) ]) @@ -74,6 +77,7 @@ describe 'TeacherClassView', -> @view.students.fakeRequests[0].respondWith({ status: 200, responseText: @students.stringify() }) @view.classroom.sessions.fakeRequests[0].respondWith({ status: 200, responseText: @levelSessions.stringify() }) @view.levels.fakeRequests[0].respondWith({ status: 200, responseText: @levels.stringify() }) + @view.prepaids.fakeRequests[0].respondWith({ status: 200, responseText: @prepaids.stringify() }) jasmine.demoEl(@view.$el) _.defer done @@ -94,14 +98,6 @@ describe 'TeacherClassView', -> # it 'sorts correctly by Progress' describe 'bulk-assign controls', -> - it 'shows alert when assigning course 2 to unenrolled students', (done) -> - expect(@view.$('.cant-assign-to-unenrolled').hasClass('visible')).toBe(false) - @view.$('.student-row .checkbox-flat').click() - @view.$('.assign-to-selected-students').click() - _.defer => - expect(@view.$('.cant-assign-to-unenrolled').hasClass('visible')).toBe(true) - done() - it 'shows alert when assigning but no students are selected', (done) -> expect(@view.$('.no-students-selected').hasClass('visible')).toBe(false) @view.$('.assign-to-selected-students').click() @@ -116,9 +112,10 @@ describe 'TeacherClassView', -> # it 'still shows the correct Course Overview progress' # - describe 'the Enrollment Status tab', -> - beforeEach -> - @view.state.set('activeTab', '#enrollment-status-tab') + describe 'the License Status tab', -> + beforeEach (done) -> + @view.state.set('activeTab', '#license-status-tab') + _.defer(done) describe 'Enroll button', -> it 'calls enrollStudents with that user when clicked', -> @@ -182,6 +179,7 @@ describe 'TeacherClassView', -> @view.students.fakeRequests[0].respondWith({ status: 200, responseText: @students.stringify() }) @view.classroom.sessions.fakeRequests[0].respondWith({ status: 200, responseText: @levelSessions.stringify() }) @view.levels.fakeRequests[0].respondWith({ status: 200, responseText: @levels.stringify() }) + @view.prepaids.fakeRequests[0].respondWith({ status: 200, responseText: @prepaids.stringify() }) jasmine.demoEl(@view.$el) _.defer done @@ -207,3 +205,110 @@ describe 'TeacherClassView', -> return true @view.$('.export-student-progress-btn').click() expect(window.open).toHaveBeenCalled() + + + describe '.assignCourse(courseID, members)', -> + beforeEach (done) -> + @classroom = factories.makeClassroom({ aceConfig: { language: 'javascript' }}, { courses: @releasedCourses, members: @students, levels: [@levels, new Levels()]}) + @courseInstances = new CourseInstances([ + factories.makeCourseInstance({}, { course: @releasedCourses.first(), @classroom, members: @students }) + factories.makeCourseInstance({}, { course: @releasedCourses.last(), @classroom, members: @students }) + ]) + + sessions = [] + @finishedStudent = @students.first() + @unfinishedStudent = @students.last() + classLanguage = @classroom.get('aceConfig')?.language + for level in @levels.models + continue if classLanguage and classLanguage is level.get('primerLanguage') + sessions.push(factories.makeLevelSession( + {state: {complete: true}, playtime: 60}, + {level, creator: @finishedStudent}) + ) + sessions.push(factories.makeLevelSession( + {state: {complete: true}, playtime: 60}, + {level: @levels.first(), creator: @unfinishedStudent}) + ) + @levelSessions = new LevelSessions(sessions) + + @view = new TeacherClassView({}, @courseInstances.first().id) + @view.classroom.fakeRequests[0].respondWith({ status: 200, responseText: @classroom.stringify() }) + @view.courses.fakeRequests[0].respondWith({ status: 200, responseText: @courses.stringify() }) + @view.courseInstances.fakeRequests[0].respondWith({ status: 200, responseText: @courseInstances.stringify() }) + @view.students.fakeRequests[0].respondWith({ status: 200, responseText: @students.stringify() }) + @view.classroom.sessions.fakeRequests[0].respondWith({ status: 200, responseText: @levelSessions.stringify() }) + @view.levels.fakeRequests[0].respondWith({ status: 200, responseText: @levels.stringify() }) + @view.prepaids.fakeRequests[0].respondWith({ status: 200, responseText: @prepaids.stringify() }) + + jasmine.demoEl(@view.$el) + _.defer done + + describe 'when no course instance exists for the given course', -> + beforeEach (done) -> + @view.courseInstances.reset() + @view.assignCourse(@courses.first().id, @students.pluck('_id').slice(0, 1)) + @view.courseInstances.wait('add').then(done) + + it 'creates the missing course instance', -> + request = jasmine.Ajax.requests.mostRecent() + expect(request.method).toBe('POST') + expect(request.url).toBe('/db/course_instance') + + it 'shows a noty if the course instance request fails', (done) -> + spyOn(window, 'noty').and.callFake(done) + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({ + status: 500, + responseText: JSON.stringify({ message: "Internal Server Error" }) + }) + + describe 'when the course is not free and some students are not enrolled', -> + beforeEach (done) -> + # first two students are unenrolled + @view.assignCourse(@courses.first().id, @students.pluck('_id').slice(0, 2)) + @view.wait('begin-redeem-for-assign-course').then(done) + + it 'enrolls all unenrolled students', (done) -> + numberOfRequests = _(@view.prepaids.models) + .map((prepaid) -> prepaid.fakeRequests.length) + .reduce((num, value) -> num + value) + expect(numberOfRequests).toBe(2) + done() + + it 'shows a noty if a redeem request fails', (done) -> + spyOn(window, 'noty').and.callFake(done) + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({ + status: 500, + responseText: JSON.stringify({ message: "Internal Server Error" }) + }) + + describe 'when there are not enough licenses available', -> + beforeEach (done) -> + # first four students are unenrolled, but only two licenses are available + @view.assignCourse(@courses.first().id, @students.pluck('_id')) + spyOn(@view, 'openModalView').and.callFake(done) + + it 'shows CoursesNotAssignedModal', -> + expect(@view.openModalView).toHaveBeenCalled() + + + describe 'when there is nothing else to do first', -> + beforeEach (done) -> + @courseInstance = @view.courseInstances.first() + @courseInstance.set('members', []) + @view.assignCourse(@courseInstance.get('courseID'), @students.pluck('_id').slice(2, 4)) + @view.wait('begin-assign-course').then(done) + + it 'adds students to the course instances', -> + request = jasmine.Ajax.requests.mostRecent() + expect(request.url).toBe("/db/course_instance/#{@courseInstance.id}/members") + expect(request.method).toBe('POST') + + it 'shows a noty if POSTing students fails', (done) -> + spyOn(window, 'noty').and.callFake(done) + request = jasmine.Ajax.requests.mostRecent() + request.respondWith({ + status: 500, + responseText: JSON.stringify({ message: "Internal Server Error" }) + })