From 2711fb521a7774aacb89614cd8113817bcd5ecc4 Mon Sep 17 00:00:00 2001 From: Harry Rein <hrein@edx.org> Date: Mon, 4 Dec 2017 12:25:21 -0500 Subject: [PATCH] Allowing a user to fulfill their entitlement on the programs dashboard. LEARNER-3438 The user can now enroll in a session, unenroll from a session or change session from a new course enrollment card on the programs dashboard. --- common/djangoapps/entitlements/models.py | 8 ++++++++ lms/static/js/learner_dashboard/models/course_card_model.js | 25 ++++++++++++++++++------- lms/static/js/learner_dashboard/models/course_entitlement_model.js | 3 +-- lms/static/js/learner_dashboard/views/course_card_view.js | 29 ++++++++++++++++++++++++++++- lms/static/js/learner_dashboard/views/course_enroll_view.js | 2 +- lms/static/js/learner_dashboard/views/course_entitlement_view.js | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------- lms/static/js/learner_dashboard/views/program_details_view.js | 1 - lms/static/lms/js/spec/main.js | 6 ++++++ lms/static/sass/_build-learner-dashboard.scss | 3 +++ lms/static/sass/_build-lms-v1.scss | 5 +++-- lms/static/sass/multicourse/_dashboard.scss | 84 +----------------------------------------------------------------------------------- lms/static/sass/views/_course-entitlements.scss | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lms/static/sass/views/_program-details.scss | 14 +++++++++++--- lms/templates/dashboard.html | 7 ++++--- lms/templates/dashboard/_dashboard_course_listing.html | 3 +-- lms/templates/learner_dashboard/course_card.underscore | 12 ++++++++---- lms/templates/learner_dashboard/course_enroll.underscore | 2 +- lms/templates/learner_dashboard/course_entitlement.underscore | 4 ++-- lms/templates/learner_dashboard/program_details_fragment.html | 4 ++++ openedx/core/djangoapps/programs/utils.py | 21 ++++++++++++++++----- themes/edx.org/lms/templates/dashboard.html | 7 ++++--- webpack.common.config.js | 6 ++++++ 22 files changed, 310 insertions(+), 158 deletions(-) create mode 100644 lms/static/sass/views/_course-entitlements.scss diff --git a/common/djangoapps/entitlements/models.py b/common/djangoapps/entitlements/models.py index daa34d4..fa80ac0 100644 --- a/common/djangoapps/entitlements/models.py +++ b/common/djangoapps/entitlements/models.py @@ -213,6 +213,14 @@ class CourseEntitlement(TimeStampedModel): """ return self.policy.is_entitlement_redeemable(self) + def to_dict(self): + """ Convert entitlement to dictionary representation. """ + return { + 'uuid': str(self.uuid), + 'course_uuid': str(self.course_uuid), + 'expired_at': self.expired_at + } + @classmethod def set_enrollment(cls, entitlement, enrollment): """ diff --git a/lms/static/js/learner_dashboard/models/course_card_model.js b/lms/static/js/learner_dashboard/models/course_card_model.js index 5d86806..34c2e26 100644 --- a/lms/static/js/learner_dashboard/models/course_card_model.js +++ b/lms/static/js/learner_dashboard/models/course_card_model.js @@ -42,6 +42,11 @@ return desiredCourseRun; }, + isEnrolledInSession: function() { + // Returns true if the user is currently enrolled in a session of the course + return _.findWhere(this.context.course_runs, {is_enrolled: true}) !== undefined; + }, + getUnselectedCourseRun: function(courseRuns) { var unselectedRun = {}, courseRun; @@ -143,8 +148,9 @@ formatDateString: function(run) { var pacingType = run.pacing_type, dateString, - start = this.get('start_date') || run.start_date, - end = this.get('end_date') || run.end_date, + start = this.valueIsDefined(run.start_date) ? run.advertised_start || run.start_date : + this.get('start_date'), + end = this.valueIsDefined(run.end_date) ? run.end_date : this.get('end_date'), now = new Date(), startDate = new Date(start), endDate = new Date(end); @@ -178,26 +184,27 @@ }, setActiveCourseRun: function(courseRun, userPreferences) { - var startDateString; - + var startDateString, + isEnrolled = this.isEnrolledInSession() && courseRun.key; if (courseRun) { if (this.valueIsDefined(courseRun.advertised_start)) { startDateString = courseRun.advertised_start; } else { startDateString = this.formatDate(courseRun.start, userPreferences); } - this.set({ certificate_url: courseRun.certificate_url, - course_run_key: courseRun.key, + course_run_key: courseRun.key || '', course_url: courseRun.course_url || '', title: this.context.title, end_date: this.formatDate(courseRun.end, userPreferences), enrollable_course_runs: this.getEnrollableCourseRuns(), is_course_ended: courseRun.is_course_ended, - is_enrolled: courseRun.is_enrolled, + is_enrolled: isEnrolled, is_enrollment_open: courseRun.is_enrollment_open, course_key: this.context.key, + user_entitlement: this.context.user_entitlement, + is_unfulfilled_entitlement: this.context.user_entitlement && !isEnrolled, marketing_url: courseRun.marketing_url, mode_slug: courseRun.type, start_date: startDateString, @@ -220,6 +227,10 @@ updateCourseRun: function(courseRunKey) { var selectedCourseRun = _.findWhere(this.get('course_runs'), {key: courseRunKey}); if (selectedCourseRun) { + // Update the current context to set the course run to the enrolled state + _.each(this.context.course_runs, function(run) { + if (run.key === selectedCourseRun.key) run.is_enrolled = true; // eslint-disable-line no-param-reassign, max-len + }); this.setActiveCourseRun(selectedCourseRun); } } diff --git a/lms/static/js/learner_dashboard/models/course_entitlement_model.js b/lms/static/js/learner_dashboard/models/course_entitlement_model.js index ebd6668..8350a89 100644 --- a/lms/static/js/learner_dashboard/models/course_entitlement_model.js +++ b/lms/static/js/learner_dashboard/models/course_entitlement_model.js @@ -1,5 +1,5 @@ /** - * Store data for the current + * Store data for the current entitlement. */ (function(define) { 'use strict'; @@ -13,7 +13,6 @@ availableSessions: [], entitlementUUID: '', currentSessionId: '', - userId: '', courseName: '' } }); diff --git a/lms/static/js/learner_dashboard/views/course_card_view.js b/lms/static/js/learner_dashboard/views/course_card_view.js index 399b829..baa2b35 100644 --- a/lms/static/js/learner_dashboard/views/course_card_view.js +++ b/lms/static/js/learner_dashboard/views/course_card_view.js @@ -11,6 +11,7 @@ 'js/learner_dashboard/views/certificate_status_view', 'js/learner_dashboard/views/expired_notification_view', 'js/learner_dashboard/views/course_enroll_view', + 'js/learner_dashboard/views/course_entitlement_view', 'text!../../../templates/learner_dashboard/course_card.underscore' ], function( @@ -24,6 +25,7 @@ CertificateStatusView, ExpiredNotificationView, CourseEnrollView, + EntitlementView, pageTpl ) { return Backbone.View.extend({ @@ -41,6 +43,8 @@ this.grade = this.context.courseData.grades[this.model.get('course_run_key')]; this.grade = this.grade * 100; this.collectionCourseStatus = this.context.collectionCourseStatus || ''; + this.entitlement = this.model.get('user_entitlement'); + this.render(); this.listenTo(this.model, 'change', this.render); }, @@ -57,7 +61,9 @@ var $upgradeMessage = this.$('.upgrade-message'), $certStatus = this.$('.certificate-status'), $expiredNotification = this.$('.expired-notification'), - expired = this.model.get('expired'); + expired = this.model.get('expired'), + courseUUID = this.model.get('uuid'), + containerSelector = '#course-' + courseUUID; this.enrollView = new CourseEnrollView({ $parentEl: this.$('.course-actions'), @@ -68,6 +74,27 @@ enrollModel: this.enrollModel }); + if (this.entitlement) { + this.sessionSelectionView = new EntitlementView({ + el: this.$(containerSelector + ' .course-entitlement-selection-container'), + $parentEl: this.$el, + courseCardModel: this.model, + enrollModel: this.enrollModel, + triggerOpenBtn: '.course-details .change-session', + courseCardMessages: '', + courseImageLink: '', + courseTitleLink: containerSelector + ' .course-details .course-title', + dateDisplayField: containerSelector + ' .course-details .course-text', + enterCourseBtn: containerSelector + ' .view-course-button', + availableSessions: JSON.stringify(this.model.get('course_runs')), + entitlementUUID: this.entitlement.uuid, + currentSessionId: this.model.isEnrolledInSession() ? + this.model.get('course_run_key') : null, + enrollUrl: this.model.get('enroll_url'), + courseHomeUrl: this.model.get('course_url') + }); + } + if (this.model.get('upgrade_url') && !(expired === true)) { this.upgradeMessage = new UpgradeMessageView({ $el: $upgradeMessage, diff --git a/lms/static/js/learner_dashboard/views/course_enroll_view.js b/lms/static/js/learner_dashboard/views/course_enroll_view.js index 00725d3..c41bc28 100644 --- a/lms/static/js/learner_dashboard/views/course_enroll_view.js +++ b/lms/static/js/learner_dashboard/views/course_enroll_view.js @@ -57,7 +57,7 @@ // Enrollment click event handled here var courseRunKey = $('.run-select').val() || this.model.get('course_run_key'); this.model.updateCourseRun(courseRunKey); - if (!this.model.get('is_enrolled')) { + if (this.model.get('is_enrolled')) { // Create the enrollment. this.enrollModel.save({ course_id: courseRunKey diff --git a/lms/static/js/learner_dashboard/views/course_entitlement_view.js b/lms/static/js/learner_dashboard/views/course_entitlement_view.js index 4ac3d8d..80a28a8 100644 --- a/lms/static/js/learner_dashboard/views/course_entitlement_view.js +++ b/lms/static/js/learner_dashboard/views/course_entitlement_view.js @@ -37,12 +37,12 @@ initialize: function(options) { // Set up models and reload view on change - this.courseCardModel = new CourseCardModel(); + this.courseCardModel = options.courseCardModel || new CourseCardModel(); + this.enrollModel = options.enrollModel; 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); @@ -51,13 +51,18 @@ 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 + // Grab elements from the parent card that work with this view + this.$parentEl = options.$parentEl; // Containing course card (must be a backbone view root el) 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 + + // Bind action elements with associated events to objects outside this view + this.$dateDisplayField = this.$parentEl ? this.$parentEl.find(options.dateDisplayField) : + $(options.dateDisplayField); // Displays current session dates + this.$triggerOpenBtn = this.$parentEl ? this.$parentEl.find(options.triggerOpenBtn) : + $(options.triggerOpenBtn); // Opens/closes session selection view this.$triggerOpenBtn.on('click', this.toggleSessionSelectionPanel.bind(this)); this.render(options); @@ -72,15 +77,17 @@ }, postRender: function() { - // Close popover on click-away + // Close any visible popovers on click-away $(document).on('click', function(e) { - if (!($(e.target).closest('.enroll-btn-initial, .popover').length)) { + if (this.$('.popover:visible').length && + !($(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)); + // Initialize focus to cancel button on popover load + $(document).on('shown.bs.popover', function() { + this.$('.final-confirmation-btn:first').focus(); }.bind(this)); }, @@ -130,7 +137,13 @@ */ var successIconEl = '<span class="fa fa-check" aria-hidden="true"></span>'; - // Update the model with the new session Id; + // With a containing backbone view, we can simply re-render the parent card + if (this.$parentEl) { + this.courseCardModel.updateCourseRun(this.currentSessionSelection); + return; + } + + // Update the model with the new session Id this.entitlementModel.set({currentSessionId: this.currentSessionSelection}); // Allow user to change session @@ -161,6 +174,11 @@ 3) Remove the messages associated with the enrolled state. 4) Remove the link from the course card image and title. */ + // With a containing backbone view, we can simply re-render the parent card + if (this.$parentEl) { + this.courseCardModel.setUnselected(); + return; + } // Update the model with the new session Id; this.entitlementModel.set({currentSessionId: this.currentSessionSelection}); @@ -198,13 +216,18 @@ }, enrollError: function() { - var errorMsgEl = HtmlUtils.HTML( - gettext('There was an error. Please reload the page and try again.') + // Display a success indicator + var errorMsgEl = HtmlUtils.joinHtml( + HtmlUtils.HTML('<span class="enroll-error">'), + gettext('There was an error. Please reload the page and try again.'), + HtmlUtils.HTML('</spandiv>') ).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')); }, @@ -237,7 +260,6 @@ enrollText = gettext('Leave Current Session'); } enrollBtnInitial.text(enrollText); - this.removeDialog(enrollBtnInitial); this.initializeVerificationDialog(enrollBtnInitial); }, @@ -263,7 +285,6 @@ */ var confirmationMsgTitle, confirmationMsgBody, - popoverDialogHtml, currentSessionId = this.entitlementModel.get('currentSessionId'), newSessionId = this.$('.session-select').find('option:selected').data('session_id'); @@ -279,38 +300,35 @@ 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 - }); - + // Re-initialize the popover invokingElement.popover({ placement: 'bottom', container: this.$el, html: true, trigger: 'click', - content: popoverDialogHtml.text + content: this.verificationTpl({ + confirmationMsgTitle: confirmationMsgTitle, + confirmationMsgBody: confirmationMsgBody + }).text }); }, - removeDialog: function(invokingElement) { + removeDialog: function(el) { /* 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(); + var $el = el instanceof jQuery ? el : this.$('.enroll-btn-initial'); + if (this.$('popover').length) { + $el.popover('dispose'); + } }, hideDialog: function(el, returnFocus) { - /* Hides the modal without removing it from the DOM. */ + /* Hides the modal if it is visible without removing it from the DOM. */ var $el = el instanceof jQuery ? el : this.$('.enroll-btn-initial'); - $el.popover('hide'); - if (returnFocus) { - $el.focus(); + if (this.$('.popover:visible').length) { + $el.popover('hide'); + if (returnFocus) { + $el.focus(); + } } }, @@ -363,12 +381,13 @@ return _.map(formattedSessionData, function(session) { var formattedSession = session; - startDate = this.formatDate(formattedSession.session_start, dateFormat); - endDate = this.formatDate(formattedSession.session_end, dateFormat); + startDate = this.formatDate(formattedSession.start, dateFormat); + endDate = this.formatDate(formattedSession.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, + start_date: startDate, + advertised_start: session.advertised_start, + end_date: endDate, pacing_type: formattedSession.pacing_type }); return formattedSession; @@ -376,7 +395,7 @@ }, formatDate: function(date, dateFormat) { - return date ? moment((new Date(date))).format(dateFormat) : null; + return date ? moment((new Date(date))).format(dateFormat) : ''; }, getAvailableSessionWithId: function(sessionId) { diff --git a/lms/static/js/learner_dashboard/views/program_details_view.js b/lms/static/js/learner_dashboard/views/program_details_view.js index 18f1c10..88034df 100644 --- a/lms/static/js/learner_dashboard/views/program_details_view.js +++ b/lms/static/js/learner_dashboard/views/program_details_view.js @@ -36,7 +36,6 @@ initialize: function(options) { this.options = options; - this.programModel = new Backbone.Model(this.options.programData); this.courseData = new Backbone.Model(this.options.courseData); this.certificateCollection = new Backbone.Collection(this.options.certificateData); diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index 0ac5f80..11145c5 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -46,6 +46,8 @@ 'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min', 'backbone.paginator': 'common/js/vendor/backbone.paginator', 'backbone-super': 'js/vendor/backbone-super', + 'popper': 'common/js/vendor/popper', + 'bootstrap': 'common/js/vendor/bootstrap', 'URI': 'xmodule_js/common_static/js/vendor/URI.min', 'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min', 'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce', @@ -197,6 +199,10 @@ 'backbone-super': { deps: ['backbone'] }, + 'bootstrap': { + deps: ['jquery', 'popper'], + exports: 'bootstrap' + }, 'paging-collection': { deps: ['jquery', 'underscore', 'backbone.paginator'] }, diff --git a/lms/static/sass/_build-learner-dashboard.scss b/lms/static/sass/_build-learner-dashboard.scss index 4a6ff0c..5b1a1ad 100644 --- a/lms/static/sass/_build-learner-dashboard.scss +++ b/lms/static/sass/_build-learner-dashboard.scss @@ -10,5 +10,8 @@ @import 'elements/program-card'; @import 'elements-v2/icons'; @import 'elements/progress-circle'; + +// Various View Styling +@import 'views/course-entitlements'; @import 'views/program-details'; @import 'views/program-list'; diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index d4477ff..2aed878 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -51,7 +51,8 @@ @import 'multicourse/survey-page'; // base - specific views -@import "views/account-settings"; +@import 'views/account-settings'; +@import 'views/course-entitlements'; @import 'views/login-register'; @import 'views/verification'; @import 'views/decoupled-verification'; @@ -59,7 +60,7 @@ @import 'views/homepage'; @import 'views/support'; @import 'views/oauth2'; -@import "views/financial-assistance"; +@import 'views/financial-assistance'; @import 'course/auto-cert'; @import 'views/api-access'; diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 6ee1904..36c495c 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -1045,6 +1045,7 @@ .related-programs-preface { @include float(left); + margin: 0 $baseline/2; font-weight: bold; } @@ -1095,89 +1096,6 @@ @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 diff --git a/lms/static/sass/views/_course-entitlements.scss b/lms/static/sass/views/_course-entitlements.scss new file mode 100644 index 0000000..22a4cf1 --- /dev/null +++ b/lms/static/sass/views/_course-entitlements.scss @@ -0,0 +1,127 @@ +// Shared styling between courses and programs dashboard +.course-entitlement-selection-container { + width: 100%; + position: relative; + flex-grow: 1; + + .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; + max-width: calc(100% - 200px); + } + + .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"); + } + + &.disabled { + pointer-events: none; + opacity: 0.5; + } + } + + @include media-breakpoint-down(xs) { + flex-direction: column; + + .session-select { + max-width: 100%; + } + + .enroll-btn-initial { + margin: $baseline/4 0; + } + } + } + + .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"); + } + } + } + } +} + +// Styling overrides specific to the programs dashboard +.program-course-card { + .course-text { + .fa-close { + color: theme-color("error"); + } + + .enroll-error { + @include margin-left($baseline/4); + + font-size: $font-size-sm; + } + + .change-session { + @include margin(0, 0, $baseline/4, $baseline/4); + + padding: 0; + font-size: $font-size-sm; + letter-spacing: normal; + } + } + + .course-entitlement-selection-container { + padding-top: $baseline/2; + + .action-header, + .action-controls .session-select{ + font-size: $font-size-sm; + + } + } +} diff --git a/lms/static/sass/views/_program-details.scss b/lms/static/sass/views/_program-details.scss index 811cfac..03f931d 100644 --- a/lms/static/sass/views/_program-details.scss +++ b/lms/static/sass/views/_program-details.scss @@ -437,7 +437,6 @@ .program-course-card { width: 100%; padding: 15px; - margin-bottom: 10px; @media (min-width: $bp-screen-md) { height: auto; @@ -445,6 +444,7 @@ .section { display: flex; + flex-direction: column; justify-content: space-between; @media (min-width: $bp-screen-md) { @@ -452,6 +452,10 @@ } } + .section:not(:last-child) { + margin-bottom: $baseline/2; + } + .section:not(:first-child) { margin-top: 0; } @@ -461,10 +465,14 @@ .course-title { font-size: 1em; - color: palette(primary, base); font-weight: 600; - text-decoration: underline; margin: 0; + + .course-title-link, + .course-title-link:visited{ + color: palette(primary, base); + text-decoration: underline; + } } .run-period { diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index b3b0973..d679d58 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -34,6 +34,7 @@ from student.models import CourseEnrollment </script> % endfor % if course_entitlements: + <!-- This is a temporary solution before we land a fix to load these through Webpack, tracked by LEARNER-3483 --> <script type="text/javascript" src="${static.url('common/js/vendor/popper.js')}"></script> <script type="text/javascript" src="${static.url('common/js/vendor/bootstrap.js')}"></script> % endif @@ -141,9 +142,9 @@ from student.models import CourseEnrollment '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, + 'advertised_start': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).advertised_start, + 'start': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).start, + '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 diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index de4b853..eddb828 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -291,8 +291,7 @@ from util.course import get_link_for_about_page, get_encoded_course_sharing_utm_ 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 }', + currentSessionId: '${ entitlement_session.course_id if entitlement_session else "" | 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 }' }); diff --git a/lms/templates/learner_dashboard/course_card.underscore b/lms/templates/learner_dashboard/course_card.underscore index b903074..aac66f0 100644 --- a/lms/templates/learner_dashboard/course_card.underscore +++ b/lms/templates/learner_dashboard/course_card.underscore @@ -1,9 +1,9 @@ -<div class="section"> +<div class="section" id="course-<%-uuid%>"> <div class="course-meta-container"> <div class="course-content"> <div class="course-details"> <h5 class="course-title"> - <% if ( marketing_url || course_url ) { %> + <% if ( (marketing_url || course_url) && !is_unfulfilled_entitlement) { %> <a href="<%- marketing_url || course_url %>" class="course-title-link"> <%- title %> </a> @@ -12,11 +12,14 @@ <% } %> </h5> <div class="course-text"> - <% if (enrolled) { %> + <% if (enrolled && !user_entitlement) { %> <span class="enrolled"><%- enrolled %>: </span> <% } %> - <% if (dateString) { %> + <% if (dateString && !is_unfulfilled_entitlement) { %> <span class="run-period"><%- dateString %></span> + <% if (user_entitlement && !is_unfulfilled_entitlement) { %> + <button class="change-session btn-link" aria-controls="change-session-<%-user_entitlement.uuid%>"> <%- gettext('Change Session')%></button> + <% } %> <% } %> </div> </div> @@ -24,6 +27,7 @@ </div> <div class="course-certificate certificate-status"></div> </div> + <div class="course-entitlement-selection-container<% if (!is_unfulfilled_entitlement && user_entitlement) { %> hidden <% } %>"></div> </div> <div class="section action-msg-view"></div> <div class="section upgrade-message"></div> diff --git a/lms/templates/learner_dashboard/course_enroll.underscore b/lms/templates/learner_dashboard/course_enroll.underscore index 5e6dd72..c12271a 100644 --- a/lms/templates/learner_dashboard/course_enroll.underscore +++ b/lms/templates/learner_dashboard/course_enroll.underscore @@ -11,7 +11,7 @@ <%- gettext('View Course') %> <% } %> </a> -<% } else { %> +<% } else if (!user_entitlement) { %> <% if (enrollable_course_runs.length > 0) { %> <% if (enrollable_course_runs.length > 1) { %> <div class="run-select-container"> diff --git a/lms/templates/learner_dashboard/course_entitlement.underscore b/lms/templates/learner_dashboard/course_entitlement.underscore index 48a2730..df19ff6 100644 --- a/lms/templates/learner_dashboard/course_entitlement.underscore +++ b/lms/templates/learner_dashboard/course_entitlement.underscore @@ -9,8 +9,8 @@ <div class="action-controls"> <select class="session-select" aria-label="<%- StringUtils.interpolate( gettext('Session Selection Dropdown for {courseName}'), { courseName: courseName }) %>"> <% _.each(availableSessions, function(session) { %> - <option data-session_id="<%- session.session_id %>"> - <% if (session.session_id === currentSessionId) { %> + <option data-session_id="<%- session.session_id || session.key %>"> + <% if ((session.session_id || session.key) === currentSessionId) { %> <%- StringUtils.interpolate( gettext('{sessionDates} - Currently Selected'), {sessionDates: session.session_dates}) %> <% } else if (session.enrollment_end){ %> <%- StringUtils.interpolate( gettext('{sessionDates} (Open until {enrollmentEnd})'), {sessionDates: session.session_dates, enrollmentEnd: session.enrollment_end}) %> diff --git a/lms/templates/learner_dashboard/program_details_fragment.html b/lms/templates/learner_dashboard/program_details_fragment.html index e3218e5..d361a27 100644 --- a/lms/templates/learner_dashboard/program_details_fragment.html +++ b/lms/templates/learner_dashboard/program_details_fragment.html @@ -9,6 +9,10 @@ from openedx.core.djangolib.js_utils import ( %> <%block name="js_extra"> +<!-- This is a temporary solution before we land a fix to load these through Webpack, tracked by LEARNER-3483 --> +<script type="text/javascript" src="${static.url('common/js/vendor/popper.js')}"></script> +<script type="text/javascript" src="${static.url('common/js/vendor/bootstrap.js')}"></script> + <%static:require_module module_name="js/learner_dashboard/program_details_factory" class_name="ProgramDetailsFactory"> ProgramDetailsFactory({ programData: ${program_data | n, dump_js_escaped_json}, diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 6a1c66c..dcaeffb 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -222,15 +222,26 @@ class ProgramProgressMeter(object): completed, in_progress, not_started = [], [], [] for course in program_copy['courses']: + try: + entitlement = CourseEntitlement.objects.get(user=self.user, course_uuid=course['uuid']) + except CourseEntitlement.DoesNotExist: + entitlement = None + if self._is_course_complete(course): completed.append(course) - elif self._is_course_enrolled(course): - course_in_progress = self._is_course_in_progress(now, course) - if course_in_progress: + elif self._is_course_enrolled(course) or entitlement: + # Show all currently enrolled courses and entitlements as in progress + if entitlement: + course['user_entitlement'] = entitlement.to_dict() + course['enroll_url'] = reverse('entitlements_api:v1:enrollments', args=[str(entitlement.uuid)]) in_progress.append(course) else: - course['expired'] = not course_in_progress - not_started.append(course) + course_in_progress = self._is_course_in_progress(now, course) + if course_in_progress: + in_progress.append(course) + else: + course['expired'] = not course_in_progress + not_started.append(course) else: not_started.append(course) diff --git a/themes/edx.org/lms/templates/dashboard.html b/themes/edx.org/lms/templates/dashboard.html index 1f365da..d91091e 100644 --- a/themes/edx.org/lms/templates/dashboard.html +++ b/themes/edx.org/lms/templates/dashboard.html @@ -35,6 +35,7 @@ from student.models import CourseEnrollment </script> % endfor % if course_entitlements: + <!-- This is a temporary solution before we land a fix to load these through Webpack, tracked by LEARNER-3483 --> <script type="text/javascript" src="${static.url('common/js/vendor/popper.js')}"></script> <script type="text/javascript" src="${static.url('common/js/vendor/bootstrap.js')}"></script> % endif @@ -136,9 +137,9 @@ from student.models import CourseEnrollment '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, + 'advertised_start': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).advertised_start, + 'start': CourseOverview.get_from_id(CourseKey.from_string(course['key'])).start, + '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 diff --git a/webpack.common.config.js b/webpack.common.config.js index 42f9210..c92c863 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -25,6 +25,8 @@ module.exports = { // LMS SingleSupportForm: './lms/static/support/jsx/single_support_form.jsx', AlertStatusBar: './lms/static/js/accessible_components/StatusBarAlert.jsx', + Bootstrap: './lms/static/common/js/vendor/bootstrap.js', + EntitlementView: './lms/static/js/learner_dashboard/views/course_entitlement_view.js', // Features CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js', @@ -63,6 +65,9 @@ module.exports = { jQuery: 'jquery', 'window.jQuery': 'jquery' }), + new webpack.ProvidePlugin({ + Popper: 'popper.js' + }), // Note: Until karma-webpack releases v3, it doesn't play well with // the CommonsChunkPlugin. We have a kludge in karma.common.conf.js @@ -169,6 +174,7 @@ module.exports = { gettext: 'gettext', jquery: 'jQuery', logger: 'Logger', + popper: 'Popper', underscore: '_', URI: 'URI' }, -- libgit2 0.26.0