From 28837c020fbee4d7e1b4ad58e3c05afafbe346fa Mon Sep 17 00:00:00 2001 From: "Albert St. Aubin" Date: Wed, 8 Nov 2017 11:09:31 -0500 Subject: [PATCH 1/2] Allowing a user to fulfill their entitlement on the course dashboard. The user can now enroll in a session, unenroll from a session or change session from a new course enrollment card on the course dashboard. --- .../student/tests/test_verification_status.py | 3 +- common/djangoapps/student/views.py | 21 +- lms/static/js/dashboard/dropdown.js | 18 +- .../course_entitlement_factory.js | 12 + .../models/course_card_model.js | 25 +- .../models/course_entitlement_model.js | 22 + .../views/course_entitlement_view.js | 399 ++++++++++++++++++ .../course_entitlement_view_spec.js | 158 +++++++ lms/static/lms/js/require-config.js | 9 + lms/static/lms/js/spec/main.js | 1 + lms/static/sass/multicourse/_dashboard.scss | 134 +++++- lms/templates/dashboard.html | 68 ++- .../dashboard/_dashboard_course_listing.html | 173 +++++--- .../course_entitlement.underscore | 33 ++ .../verification_popover.underscore | 16 + openedx/core/djangoapps/catalog/utils.py | 2 +- themes/edx.org/lms/templates/dashboard.html | 75 +++- 17 files changed, 1025 insertions(+), 144 deletions(-) create mode 100644 lms/static/js/learner_dashboard/course_entitlement_factory.js create mode 100644 lms/static/js/learner_dashboard/models/course_entitlement_model.js create mode 100644 lms/static/js/learner_dashboard/views/course_entitlement_view.js create mode 100644 lms/static/js/spec/learner_dashboard/course_entitlement_view_spec.js create mode 100644 lms/templates/learner_dashboard/course_entitlement.underscore create mode 100644 lms/templates/learner_dashboard/verification_popover.underscore diff --git a/common/djangoapps/student/tests/test_verification_status.py b/common/djangoapps/student/tests/test_verification_status.py index 08093f2448..f320be7aa2 100644 --- a/common/djangoapps/student/tests/test_verification_status.py +++ b/common/djangoapps/student/tests/test_verification_status.py @@ -371,8 +371,7 @@ class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase): # Verify that the correct banner color is rendered self.assertContains( response, - "
".format( - self.MODE_CLASSES[status], self.course.id) + "
now) { - dateString += ' - Starts ' + start; + if (start) { + dateString = startDate > now ? + StringUtils.interpolate(gettext('(Self-paced) Starts {start}'), {start: start}) : + StringUtils.interpolate(gettext('(Self-paced) Started {start}'), {start: start}); } else if (end && endDate > now) { - dateString += ' - Ends ' + end; + dateString = StringUtils.interpolate(gettext('(Self-paced) Ends {end}'), {end: end}); } else if (end && endDate < now) { - dateString += ' - Ended ' + end; + dateString = StringUtils.interpolate(gettext('(Self-paced) Ended {end}'), {end: end}); } } else { if (start && end) { dateString = start + ' - ' + end; } else if (start) { - dateString = 'Starts ' + start; + dateString = startDate > now ? + StringUtils.interpolate(gettext('Starts {start}'), {start: start}) : + StringUtils.interpolate(gettext('Started {start}'), {start: start}); } else if (end) { - dateString = 'Ends ' + end; + dateString = StringUtils.interpolate(gettext('Ends {end}'), {end: end}); } } return dateString; diff --git a/lms/static/js/learner_dashboard/models/course_entitlement_model.js b/lms/static/js/learner_dashboard/models/course_entitlement_model.js new file mode 100644 index 0000000000..ebd666822d --- /dev/null +++ b/lms/static/js/learner_dashboard/models/course_entitlement_model.js @@ -0,0 +1,22 @@ +/** + * Store data for the current + */ +(function(define) { + 'use strict'; + + define([ + 'backbone' + ], + function(Backbone) { + return Backbone.Model.extend({ + defaults: { + availableSessions: [], + entitlementUUID: '', + currentSessionId: '', + userId: '', + courseName: '' + } + }); + } + ); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/learner_dashboard/views/course_entitlement_view.js b/lms/static/js/learner_dashboard/views/course_entitlement_view.js new file mode 100644 index 0000000000..cf974973b9 --- /dev/null +++ b/lms/static/js/learner_dashboard/views/course_entitlement_view.js @@ -0,0 +1,399 @@ +// This is required for karma testing due to a known issue in Bootstrap-v4: https://github.com/twbs/bootstrap/pull/22888 +// The issue is that bootstrap tries to access Popper's global Popper object which is not initialized on loading +// from the karma configuration. The next version of bootstrap (>v4.2) will solve this issue. +// Once this is resolved, we should import bootstrap through require-config.js and main.js (for jasmine testing) +var defineFn = require || RequireJS.require; // eslint-disable-line global-require +var Popper = defineFn(['common/js/vendor/popper']); +defineFn(['common/js/vendor/bootstrap']); + +(function(define) { + 'use strict'; + + define(['backbone', + 'jquery', + 'underscore', + 'gettext', + 'moment', + 'edx-ui-toolkit/js/utils/html-utils', + 'js/learner_dashboard/models/course_entitlement_model', + 'js/learner_dashboard/models/course_card_model', + 'text!../../../templates/learner_dashboard/course_entitlement.underscore', + 'text!../../../templates/learner_dashboard/verification_popover.underscore' + ], + function( + Backbone, + $, + _, + gettext, + moment, + HtmlUtils, + EntitlementModel, + CourseCardModel, + pageTpl, + verificationPopoverTpl + ) { + return Backbone.View.extend({ + tpl: HtmlUtils.template(pageTpl), + verificationTpl: HtmlUtils.template(verificationPopoverTpl), + + events: { + 'change .session-select': 'updateEnrollBtn', + 'click .enroll-btn': 'handleEnrollChange', + 'keydown .final-confirmation-btn': 'handleVerificationPopoverA11y', + 'click .popover-dismiss': 'hideDialog' + }, + + initialize: function(options) { + // Set up models and reload view on change + this.courseCardModel = new CourseCardModel(); + this.entitlementModel = new EntitlementModel({ + availableSessions: this.formatDates(JSON.parse(options.availableSessions)), + entitlementUUID: options.entitlementUUID, + currentSessionId: options.currentSessionId, + userId: options.userId, + courseName: options.courseName + }); + this.listenTo(this.entitlementModel, 'change', this.render); + + // Grab URLs that handle changing of enrollment and entering a newly selected session. + this.enrollUrl = options.enrollUrl; + this.courseHomeUrl = options.courseHomeUrl; + + // Grab elements from the parent card that work with this view and bind associated events + this.$triggerOpenBtn = $(options.triggerOpenBtn); // Opens/closes session selection view + this.$dateDisplayField = $(options.dateDisplayField); // Displays current session dates + this.$enterCourseBtn = $(options.enterCourseBtn); // Button link to course home page + this.$courseCardMessages = $(options.courseCardMessages); // Additional session messages + this.$courseTitleLink = $(options.courseTitleLink); // Title link to course home page + this.$courseImageLink = $(options.courseImageLink); // Image link to course home page + this.$triggerOpenBtn.on('click', this.toggleSessionSelectionPanel.bind(this)); + + this.render(options); + this.postRender(); + }, + + render: function() { + HtmlUtils.setHtml(this.$el, this.tpl(this.entitlementModel.toJSON())); + this.delegateEvents(); + this.updateEnrollBtn(); + return this; + }, + + postRender: function() { + // Close popover on click-away + $(document).on('click', function(e) { + if (!($(e.target).closest('.enroll-btn-initial, .popover').length)) { + this.hideDialog(this.$('.enroll-btn-initial')); + } + }.bind(this)); + + this.$('.enroll-btn-initial').click(function(e) { + this.showDialog($(e.target)); + }.bind(this)); + }, + + handleEnrollChange: function() { + /* + Handles enrolling in a course, unenrolling in a session and changing session. + The new session id is stored as a data attribute on the option in the session-select element. + */ + var isLeavingSession; + + // Do not allow for enrollment when button is disabled + if (this.$('.enroll-btn-initial').hasClass('disabled')) return; + + // Grab the id for the desired session, an leave session event will return null + this.currentSessionSelection = this.$('.session-select') + .find('option:selected').data('session_id'); + isLeavingSession = !this.currentSessionSelection; + + // Display the indicator icon + HtmlUtils.setHtml(this.$dateDisplayField, + HtmlUtils.HTML('') + ); + + $.ajax({ + type: isLeavingSession ? 'DELETE' : 'POST', + url: this.enrollUrl, + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({ + course_run_id: this.currentSessionSelection + }), + statusCode: { + 201: _.bind(this.enrollSuccess, this), + 204: _.bind(this.unenrollSuccess, this) + }, + error: _.bind(this.enrollError, this) + }); + }, + + enrollSuccess: function(data) { + /* + Update external elements on the course card to represent the now available course session. + + 1) Show the change session toggle button. + 2) Add the new session's dates to the date field on the main course card. + 3) Hide the 'View Course' button to the course card. + */ + var successIconEl = ''; + + // Update the model with the new session Id; + this.entitlementModel.set({currentSessionId: this.currentSessionSelection}); + + // Allow user to change session + this.$triggerOpenBtn.removeClass('hidden'); + + // Display a success indicator + HtmlUtils.setHtml(this.$dateDisplayField, + HtmlUtils.joinHtml( + HtmlUtils.HTML(successIconEl), + this.getAvailableSessionWithId(data.course_run_id).session_dates + ) + ); + + // Ensure the view course button links to new session home page and place focus there + this.$enterCourseBtn + .attr('href', this.formatCourseHomeUrl(data.course_run_id)) + .removeClass('hidden') + .focus(); + this.toggleSessionSelectionPanel(); + }, + + unenrollSuccess: function() { + /* + Update external elements on the course card to represent the unenrolled state. + + 1) Hide the change session button and the date field. + 2) Hide the 'View Course' button. + 3) Remove the messages associated with the enrolled state. + 4) Remove the link from the course card image and title. + */ + + // Update the model with the new session Id; + this.entitlementModel.set({currentSessionId: this.currentSessionSelection}); + + // Reset the card contents to the unenrolled state + this.$triggerOpenBtn.addClass('hidden'); + this.$enterCourseBtn.addClass('hidden'); + this.$courseCardMessages.remove(); + this.$('.enroll-btn-initial').focus(); + HtmlUtils.setHtml( + this.$dateDisplayField, + HtmlUtils.joinHtml( + HtmlUtils.HTML(''), + HtmlUtils.HTML(gettext('You must select a session to access the course.')) + ) + ); + + // Remove links to previously enrolled sessions + this.$courseImageLink.replaceWith( // xss-lint: disable=javascript-jquery-insertion + HtmlUtils.joinHtml( + HtmlUtils.HTML('
'), + HtmlUtils.HTML(this.$courseImageLink.html()), + HtmlUtils.HTML('
') + ).text + ); + this.$courseTitleLink.replaceWith( // xss-lint: disable=javascript-jquery-insertion + HtmlUtils.joinHtml( + HtmlUtils.HTML(''), + this.$courseTitleLink.text(), + HtmlUtils.HTML('') + ).text + ); + }, + + enrollError: function() { + var errorMsgEl = HtmlUtils.HTML( + gettext('There was an error. Please reload the page and try again.') + ).text; + this.$dateDisplayField + .find('.fa.fa-spin') + .removeClass('fa-spin fa-spinner') + .addClass('fa-close'); + this.$dateDisplayField.append(errorMsgEl); + this.hideDialog(this.$('.enroll-btn-initial')); + }, + + updateEnrollBtn: function() { + /* + This function is invoked on load, on opening the view and on changing the option on the session + selection dropdown. It plays three roles: + 1) Enables and disables enroll button + 2) Changes text to describe the action taken + 3) Formats the confirmation popover to allow for two step authentication + */ + var enrollText, + currentSessionId = this.entitlementModel.get('currentSessionId'), + newSessionId = this.$('.session-select').find('option:selected').data('session_id'), + enrollBtnInitial = this.$('.enroll-btn-initial'); + + // Disable the button if the user is already enrolled in that session. + if (currentSessionId === newSessionId) { + enrollBtnInitial.addClass('disabled'); + this.removeDialog(enrollBtnInitial); + return; + } + enrollBtnInitial.removeClass('disabled'); + + // Update button text specifying if the user is initially enrolling, changing or leaving a session. + if (newSessionId) { + enrollText = currentSessionId ? gettext('Change Session') : gettext('Select Session'); + } else { + enrollText = gettext('Leave Current Session'); + } + enrollBtnInitial.text(enrollText); + this.removeDialog(enrollBtnInitial); + this.initializeVerificationDialog(enrollBtnInitial); + }, + + toggleSessionSelectionPanel: function() { + /* + Opens and closes the session selection panel. + */ + this.$el.toggleClass('hidden'); + if (!this.$el.hasClass('hidden')) { + // Set focus to the session selection for a11y purposes + this.$('.session-select').focus(); + this.hideDialog(this.$('.enroll-btn-initial')); + } + this.updateEnrollBtn(); + }, + + initializeVerificationDialog: function(invokingElement) { + /* + Instantiates an instance of the Bootstrap v4 dialog modal and attaches it to the passed in element. + + This dialog acts as the second step in verifying the user's action to select, change or leave an + available course session. + */ + var confirmationMsgTitle, + confirmationMsgBody, + popoverDialogHtml, + currentSessionId = this.entitlementModel.get('currentSessionId'), + newSessionId = this.$('.session-select').find('option:selected').data('session_id'); + + // Update the button popover text to enable two step authentication. + if (newSessionId) { + confirmationMsgTitle = !currentSessionId ? + gettext('Are you sure you want to select this session?') : + gettext('Are you sure you want to change to a different session?'); + confirmationMsgBody = !currentSessionId ? '' : + gettext('Any course progress or grades from your current session will be lost.'); + } else { + confirmationMsgTitle = gettext('Are you sure that you want to leave this session?'); + confirmationMsgBody = gettext('Any course progress or grades from your current session will be lost.'); // eslint-disable-line max-len + } + + // Remove existing popover and re-initialize + popoverDialogHtml = this.verificationTpl({ + confirmationMsgTitle: confirmationMsgTitle, + confirmationMsgBody: confirmationMsgBody + }); + + invokingElement.popover({ + placement: 'bottom', + container: this.$el, + html: true, + trigger: 'click', + content: popoverDialogHtml.text + }); + }, + + removeDialog: function(invokingElement) { + /* Removes the Bootstrap v4 dialog modal from the update session enrollment button. */ + invokingElement.popover('dispose'); + }, + + showDialog: function(invokingElement) { + /* Given an element with an associated dialog modal, shows the modal. */ + invokingElement.popover('show'); + this.$('.final-confirmation-btn:first').focus(); + }, + + hideDialog: function(el, returnFocus) { + /* Hides the modal without removing it from the DOM. */ + var $el = el instanceof jQuery ? el : this.$('.enroll-btn-initial'); + $el.popover('hide'); + if (returnFocus) { + $el.focus(); + } + }, + + handleVerificationPopoverA11y: function(e) { + /* Ensure that the second step verification popover is treated as an a11y compliant dialog */ + var $nextButton, + $verificationOption = $(e.target), + openButton = $(e.target).closest('.course-entitlement-selection-container') + .find('.enroll-btn-initial'); + if (e.key === 'Tab') { + e.preventDefault(); + $nextButton = $verificationOption.is(':first-child') ? + $verificationOption.next('.final-confirmation-btn') : + $verificationOption.prev('.final-confirmation-btn'); + $nextButton.focus(); + } else if (e.key === 'Escape') { + this.hideDialog(openButton); + openButton.focus(); + } + }, + + formatCourseHomeUrl: function(sessionKey) { + /* + Takes the base course home URL and updates it with the new session id, leveraging the + the fact that all course keys contain a '+' symbol. + */ + var oldSessionKey = this.courseHomeUrl.split('/') + .filter( + function(urlParam) { + return urlParam.indexOf('+') > 0; + } + )[0]; + return this.courseHomeUrl.replace(oldSessionKey, sessionKey); + }, + + formatDates: function(sessionData) { + /* + Takes a data object containing the upcoming available sessions for an entitlement and returns + the object with a session_dates attribute representing a formatted date string that highlights + the start and end dates of the particular session. + */ + var formattedSessionData = sessionData, + startDate, + endDate, + dateFormat; + // Set the date format string to the user's selected language + moment.locale(document.documentElement.lang); + dateFormat = moment.localeData().longDateFormat('L').indexOf('DD') > + moment.localeData().longDateFormat('L').indexOf('MM') ? 'MMMM D, YYYY' : 'D MMMM, YYYY'; + + return _.map(formattedSessionData, function(session) { + var formattedSession = session; + startDate = this.formatDate(formattedSession.session_start, dateFormat); + endDate = this.formatDate(formattedSession.session_end, dateFormat); + formattedSession.enrollment_end = this.formatDate(formattedSession.enrollment_end, dateFormat); + formattedSession.session_dates = this.courseCardModel.formatDateString({ + start_date: session.session_start_advertised || startDate, + end_date: session.session_start_advertised ? null : endDate, + pacing_type: formattedSession.pacing_type + }); + return formattedSession; + }, this); + }, + + formatDate: function(date, dateFormat) { + return date ? moment((new Date(date))).format(dateFormat) : null; + }, + + getAvailableSessionWithId: function(sessionId) { + /* Returns an available session given a sessionId */ + return this.entitlementModel.get('availableSessions').find(function(session) { + return session.session_id === sessionId; + }); + } + }); + } + ); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/spec/learner_dashboard/course_entitlement_view_spec.js b/lms/static/js/spec/learner_dashboard/course_entitlement_view_spec.js new file mode 100644 index 0000000000..934613d929 --- /dev/null +++ b/lms/static/js/spec/learner_dashboard/course_entitlement_view_spec.js @@ -0,0 +1,158 @@ +define([ + 'backbone', + 'underscore', + 'jquery', + 'js/learner_dashboard/models/course_entitlement_model', + 'js/learner_dashboard/views/course_entitlement_view' +], function(Backbone, _, $, CourseEntitlementModel, CourseEntitlementView) { + 'use strict'; + + describe('Course Entitlement View', function() { + var view = null, + setupView, + selectOptions, + entitlementAvailableSessions, + initialSessionId, + entitlementUUID = 'a9aiuw76a4ijs43u18', + testSessionIds = ['test_session_id_1', 'test_session_id_2']; + + setupView = function(isAlreadyEnrolled) { + setFixtures('
'); + + initialSessionId = isAlreadyEnrolled ? testSessionIds[0] : ''; + entitlementAvailableSessions = [{ + enrollment_end: null, + session_start: '2013-02-05T05:00:00+00:00', + pacing_type: 'instructor_paced', + session_id: testSessionIds[0], + session_end: null + }, { + enrollment_end: '2017-12-22T03:30:00Z', + session_start: '2018-01-03T13:00:00+00:00', + pacing_type: 'self_paced', + session_id: testSessionIds[1], + session_end: '2018-03-09T21:30:00+00:00' + }]; + + view = new CourseEntitlementView({ + el: '.course-entitlement-selection-container', + triggerOpenBtn: '#course-card-0 .change-session', + courseCardMessages: '#course-card-0 .messages-list > .message', + courseTitleLink: '#course-card-0 .course-title a', + courseImageLink: '#course-card-0 .wrapper-course-image > a', + dateDisplayField: '#course-card-0 .info-date-block', + enterCourseBtn: '#course-card-0 .enter-course', + availableSessions: JSON.stringify(entitlementAvailableSessions), + entitlementUUID: entitlementUUID, + currentSessionId: initialSessionId, + userId: '1', + enrollUrl: '/api/enrollment/v1/enrollment', + courseHomeUrl: '/courses/course-v1:edX+DemoX+Demo_Course/course/' + }); + }; + + afterEach(function() { + if (view) view.remove(); + }); + + describe('Initialization of view', function() { + it('Should create a entitlement view element', function() { + setupView(false); + expect(view).toBeDefined(); + }); + }); + + describe('Available Sessions Select - Unfulfilled Entitlement', function() { + beforeEach(function() { + setupView(false); + selectOptions = view.$('.session-select').find('option'); + }); + + it('Select session dropdown should show all available course runs and a coming soon option.', function() { + expect(selectOptions.length).toEqual(entitlementAvailableSessions.length + 1); + }); + + it('Self paced courses should have visual indication in the selection option.', function() { + var selfPacedOptionIndex = _.findIndex(entitlementAvailableSessions, function(session) { + return session.pacing_type === 'self_paced'; + }); + var selfPacedOption = selectOptions[selfPacedOptionIndex]; + expect(selfPacedOption && selfPacedOption.text.includes('(Self-paced)')).toBe(true); + }); + + it('Courses with an an enroll by date should indicate so on the selection option.', function() { + var enrollEndSetOptionIndex = _.findIndex(entitlementAvailableSessions, function(session) { + return session.enrollment_end !== null; + }); + var enrollEndSetOption = selectOptions[enrollEndSetOptionIndex]; + expect(enrollEndSetOption && enrollEndSetOption.text.includes('Open until')).toBe(true); + }); + + it('Title element should correctly indicate the expected behavior.', function() { + expect(view.$('.action-header').text().includes( + 'To access the course, select a session.' + )).toBe(true); + }); + }); + + describe('Available Sessions Select - Fulfilled Entitlement', function() { + beforeEach(function() { + setupView(true); + selectOptions = view.$('.session-select').find('option'); + }); + + it('Select session dropdown should show available course runs, coming soon and leave options.', function() { + expect(selectOptions.length).toEqual(entitlementAvailableSessions.length + 2); + }); + + it('Select session dropdown should allow user to leave the current session.', function() { + var leaveSessionOption = selectOptions[selectOptions.length - 1]; + expect(leaveSessionOption.text.includes('Leave the current session and decide later')).toBe(true); + }); + + it('Currently selected session should be specified in the dropdown options.', function() { + var selectedSessionIndex = _.findIndex(entitlementAvailableSessions, function(session) { + return initialSessionId === session.session_id; + }); + expect(selectOptions[selectedSessionIndex].text.includes('Currently Selected')).toBe(true); + }); + + it('Title element should correctly indicate the expected behavior.', function() { + expect(view.$('.action-header').text().includes( + 'Change to a different session or leave the current session.' + )).toBe(true); + }); + }); + + describe('Select Session Action Button and popover behavior - Unfulfilled Entitlement', function() { + beforeEach(function() { + setupView(false); + }); + + it('Change session button should have the correct text.', function() { + expect(view.$('.enroll-btn-initial').text() === 'Select Session').toBe(true); + }); + + it('Select session button should show popover when clicked.', function() { + view.$('.enroll-btn-initial').click(); + expect(view.$('.verification-modal').length > 0).toBe(true); + }); + }); + + describe('Change Session Action Button and popover behavior - Fulfilled Entitlement', function() { + beforeEach(function() { + setupView(true); + selectOptions = view.$('.session-select').find('option'); + }); + + it('Change session button should show correct text.', function() { + expect(view.$('.enroll-btn-initial').text().trim() === 'Change Session').toBe(true); + }); + + it('Switch session button should be disabled when on the currently enrolled session.', function() { + expect(view.$('.enroll-btn-initial')).toHaveClass('disabled'); + }); + }); + }); +} +); diff --git a/lms/static/lms/js/require-config.js b/lms/static/lms/js/require-config.js index ba785e1d51..c438c2137c 100644 --- a/lms/static/lms/js/require-config.js +++ b/lms/static/lms/js/require-config.js @@ -100,6 +100,8 @@ 'string_utils': 'js/src/string_utils', 'utility': 'js/src/utility', 'draggabilly': 'js/vendor/draggabilly', + 'popper': 'common/js/vendor/popper', + 'bootstrap': 'common/js/vendor/bootstrap', // Files needed by OVA 'annotator': 'js/vendor/ova/annotator-full', @@ -206,6 +208,13 @@ 'grouping-annotator': { deps: ['annotator'] }, + 'popper': { + exports: 'Popper' + }, + 'bootstrap': { + deps: ['jquery', 'popper'], + exports: 'bootstrap' + }, 'ova': { exports: 'ova', deps: [ diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index 0ac5f80d62..f52a208565 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -762,6 +762,7 @@ 'js/spec/learner_dashboard/unenroll_view_spec.js', 'js/spec/learner_dashboard/course_card_view_spec.js', 'js/spec/learner_dashboard/course_enroll_view_spec.js', + 'js/spec/learner_dashboard/course_entitlement_view_spec.js', 'js/spec/markdown_editor_spec.js', 'js/spec/dateutil_factory_spec.js', 'js/spec/navigation_spec.js', diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index b6e0b51151..6ee1904ea6 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -163,11 +163,31 @@ display: inline-block; } - .info-date-block { - @extend %t-title7; - - color: $gray; // WCAG 2.0 AA compliant + .info-date-block-container { display: block; + + .info-date-block{ + @extend %t-title7; + + color: $gray; // WCAG 2.0 AA compliant + + .fa-close { + color: theme-color("error"); + } + + .fa-check { + color: theme-color("success"); + } + } + + .change-session { + @extend %t-title7; + @include margin(0, 0, 0, $baseline/4); + + padding: 0; + border: none; + letter-spacing: normal; + } } } @@ -633,18 +653,20 @@ .message-copy .copy { @extend %t-copy-sub1; - margin: 2px 0 0 0; + margin: 2px 0 0; } // CASE: expandable &.is-expandable { .wrapper-tip { - .message-title, .message-copy { + .message-title, + .message-copy { margin-bottom: 0; display: inline-block; } - .message-title .value, .message-copy { + .message-title .value, + .message-copy { @include transition(color $tmg-f2 ease-in-out 0s); } @@ -652,7 +674,9 @@ &:hover { cursor: pointer; - .message-title .value, .message-copy, .ui-toggle-expansion { + .message-title .value, + .message-copy, + .ui-toggle-expansion { color: $link-color; } } @@ -789,7 +813,7 @@ .action-view-consent { @extend %btn-pl-white-base; @include float(right); - + &.archived { @extend %btn-pl-default-base; } @@ -1071,6 +1095,89 @@ @include padding($baseline/2, $baseline, $baseline/2, $baseline/2); } } + + // Course Entitlement Session Selection + .course-entitlement-selection-container { + background-color: theme-color("inverse"); + + .action-header { + padding-bottom: $baseline/4; + font-weight: $font-weight-bold; + color: theme-color("dark"); + } + + .action-controls { + display: flex; + + .session-select { + background-color: theme-color("inverse"); + height: $baseline*1.5; + flex-grow: 5; + margin-bottom: $baseline*0.4; + } + + .enroll-btn-initial { + @include margin-left($baseline); + + height: $baseline*1.5; + flex-grow: 1; + letter-spacing: 0; + background: theme-color("inverse"); + border-color: theme-color("primary"); + color: theme-color("primary"); + text-shadow: none; + font-size: $font-size-base; + padding: 0; + box-shadow: none; + border-radius: $border-radius-sm; + transition: all 0.4s ease-out; + + &:hover { + background: theme-color("primary"); + border-color: theme-color("primary"); + color: theme-color("inverse"); + } + } + + @include media-breakpoint-down(xs) { + flex-direction: column; + + .enroll-btn-initial { + margin: $baseline/4 0 $baseline/4; + } + } + } + + .popover { + .popover-title { + margin-bottom: $baseline/2; + } + + .action-items { + display: flex; + justify-content: space-between; + margin-top: $baseline/2; + + .final-confirmation-btn { + box-shadow: none; + border: 1px solid theme-color("dark"); + background: none; + color: theme-color("dark"); + text-shadow: none; + letter-spacing: 0; + flex-grow: 1; + margin: 0 $baseline/4; + padding: $baseline/10 $baseline; + font-size: $font-size-base; + + &:hover { + background: theme-color("primary"); + color: theme-color("inverse"); + } + } + } + } + } } // CASE: empty dashboard @@ -1323,11 +1430,11 @@ p.course-block { padding: 6px 32px 7px; text-align: center; margin-top: 16px; - opacity:0.5; - background:#808080; - border:0; + opacity: 0.5; + background: #808080; + border: 0; color: theme-color("inverse"); - box-shadow:none; + box-shadow: none; &.archived { @include button(simple, $button-archive-color); @@ -1557,5 +1664,4 @@ a.fade-cover { color: theme-color("inverse"); text-decoration: none; } - } diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index b70271f6bf..c60337be24 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -6,11 +6,16 @@ from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ from django.template import RequestContext +from entitlements.models import CourseEntitlement import third_party_auth from third_party_auth import pipeline +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.markup import HTML, Text + +from student.models import CourseEnrollment %> <% @@ -108,7 +113,7 @@ from openedx.core.djangolib.markup import HTML, Text
<%include file="learner_dashboard/_dashboard_navigation_courses.html"/> - % if len(course_enrollments) > 0: + % if len(course_entitlements + course_enrollments) > 0:
    <% share_settings = configuration_helpers.get_value( @@ -116,20 +121,53 @@ from openedx.core.djangolib.markup import HTML, Text getattr(settings, 'SOCIAL_SHARING_SETTINGS', {}) ) %> - % for dashboard_index, enrollment in enumerate(course_enrollments): - <% show_courseware_link = (enrollment.course_id in show_courseware_links_for) %> - <% cert_status = cert_statuses.get(enrollment.course_id) %> - <% can_unenroll = (not cert_status) or cert_status.get('can_unenroll') %> - <% credit_status = credit_statuses.get(enrollment.course_id) %> - <% show_email_settings = (enrollment.course_id in show_email_settings_for) %> - <% course_mode_info = all_course_modes.get(enrollment.course_id) %> - <% is_paid_course = (enrollment.course_id in enrolled_courses_either_paid) %> - <% is_course_blocked = (enrollment.course_id in block_courses) %> - <% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %> - <% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %> - <% related_programs = inverted_programs.get(unicode(enrollment.course_id)) %> - <% show_consent_link = (enrollment.course_id in consent_required_courses) %> - <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name' /> + % for dashboard_index, enrollment in enumerate(course_entitlements + course_enrollments): + <% + # Check if the course run is an entitlement and if it has an associated session + entitlement = enrollment if isinstance(enrollment, CourseEntitlement) else None + entitlement_session = entitlement.enrollment_course_run if entitlement else None + + is_fulfilled_entitlement = True if entitlement and entitlement_session else False + is_unfulfilled_entitlement = True if entitlement and not entitlement_session else False + + entitlement_available_sessions = [] + if entitlement: + # Grab the available, enrollable sessions for a given entitlement and scrape them for relevant attributes + entitlement_available_sessions = [{ + 'session_id': course['key'], + 'enrollment_end': course['enrollment_end'], + 'pacing_type': course['pacing_type'], + 'session_start_advertised': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).advertised_start, + 'session_start': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).start, + 'session_end': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).end, + } for course in course_entitlement_available_sessions[str(entitlement.uuid)]] + if is_fulfilled_entitlement: + # If the user has a fulfilled entitlement, pass through the entitlements CourseEnrollment object + enrollment = entitlement_session + else: + # If the user has an unfulfilled entitlement, pass through a bare CourseEnrollment object built off of the next available session + upcoming_sessions = course_entitlement_available_sessions[str(entitlement.uuid)] + next_session = upcoming_sessions[0] if upcoming_sessions else None + if not next_session: + continue + enrollment = CourseEnrollment(user=user, course_id=next_session['key'], mode=next_session['type']) + + session_id = enrollment.course_id + show_courseware_link = (session_id in show_courseware_links_for) + cert_status = cert_statuses.get(session_id) + can_unenroll = (not cert_status) or cert_status.get('can_unenroll') if not unfulfilled_entitlement else False + credit_status = credit_statuses.get(session_id) + show_email_settings = (session_id in show_email_settings_for) + course_mode_info = all_course_modes.get(session_id) + is_paid_course = True if entitlement else (session_id in enrolled_courses_either_paid) + is_course_blocked = (session_id in block_courses) + course_verification_status = verification_status_by_course.get(session_id, {}) + course_requirements = courses_requirements_not_met.get(session_id) + related_programs = inverted_programs.get(unicode(session_id)) + show_consent_link = (session_id in consent_required_courses) + course_overview = enrollment.course_overview + %> + <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name' /> % endfor
diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index 21a7c54139..c09ad88894 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -1,4 +1,4 @@ -<%page args="course_overview, enrollment, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name" expression_filter="h"/> +<%page args="course_overview, enrollment, entitlement, entitlement_session, course_card_index, is_unfulfilled_entitlement, is_fulfilled_entitlement, entitlement_available_sessions, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name" expression_filter="h"/> <%! import urllib @@ -59,11 +59,12 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ lang="${course_overview.language}" % endif > -
+
<% course_target = reverse(course_home_url_name(course_overview.id), args=[unicode(course_overview.id)]) %> -
+
+

${_('Course details')}

- % if show_courseware_link: + % if show_courseware_link and not is_unfulfilled_entitlement: % if not is_course_blocked: ${course_overview.display_name_with_default} % else: @@ -126,18 +127,27 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ endif %> - % if isinstance(course_date, basestring): - ${_(container_string).format(date=course_date)} - % elif course_date is not None: - <% - course_date_string = course_date.strftime('%Y-%m-%dT%H:%M:%S%z') - %> - + + % if is_unfulfilled_entitlement: + + + ${_('You must select a session to access the course.')} + + % else: + % if isinstance(course_date, basestring): + ${container_string.format(date=course_date)} + % elif course_date is not None: + + % endif % endif + % if entitlement: + + % endif +

- % if show_courseware_link: + % if show_courseware_link or is_unfulfilled_entitlement: % if course_overview.has_ended(): % if not is_course_blocked: ${_('View Archived Course')} ${course_overview.display_name_with_default} @@ -146,7 +156,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ % endif % else: % if not is_course_blocked: - ${_('View Course')} ${course_overview.display_name_with_default} + ${_('View Course')} ${course_overview.display_name_with_default} % else: ${_('View Course')} ${course_overview.display_name_with_default} % endif @@ -205,68 +215,91 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ % endif % endif - % endif -
- -
- +
+
+ % endif
-
+
- % if related_programs: + + % if entitlement: +
+ <%static:require_module module_name="js/learner_dashboard/course_entitlement_factory" class_name="EntitlementFactory"> + EntitlementFactory({ + el: '${ '#course-card-' + str(course_card_index) + ' .course-entitlement-selection-container' | n, js_escaped_string }', + triggerOpenBtn: '${ '#course-card-' + str(course_card_index) + ' .change-session' | n, js_escaped_string }', + courseCardMessages: '${ '#course-card-' + str(course_card_index) + ' .messages-list > .message' | n, js_escaped_string }', + courseTitleLink: '${ '#course-card-' + str(course_card_index) + ' .course-title a' | n, js_escaped_string }', + courseImageLink: '${ '#course-card-' + str(course_card_index) + ' .wrapper-course-image > a' | n, js_escaped_string }', + dateDisplayField: '${ '#course-card-' + str(course_card_index) + ' .info-date-block' | n, js_escaped_string }', + enterCourseBtn: '${ '#course-card-' + str(course_card_index) + ' .enter-course' | n, js_escaped_string }', + availableSessions: '${ entitlement_available_sessions | n, dump_js_escaped_json }', + entitlementUUID: '${ entitlement.course_uuid | n, js_escaped_string }', + currentSessionId: '${ entitlement_session.course_id if entitlement_session else '' | n, js_escaped_string }', + userId: '${ user.id | n, js_escaped_string }', + enrollUrl: '${ reverse('entitlements_api:v1:enrollments', args=[str(entitlement.uuid)]) | n, js_escaped_string }', + courseHomeUrl: '${ course_target | n, js_escaped_string }' + }); + + %endif + + % if related_programs and not entitlement: % endif - % if course_mode_info['show_upsell']: + % if course_mode_info and course_mode_info['show_upsell'] and not entitlement:

@@ -410,7 +443,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ % endif

-
+