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 000000000..7af29f522 Binary files /dev/null and b/app/assets/images/pages/courses/how_to_apply_licenses.png differ 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" }) + })