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