Add entitlement unenrollment survey
Updates behavior post unenrollment, also refactors accessible_modal to enable the unenrollment survey to remain accessible after the content in the modal changes (to the survey). mloturco/learner-3524
This commit is contained in:
@@ -33,7 +33,111 @@ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVE
|
||||
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
*/
|
||||
var $focusedElementBeforeModal;
|
||||
|
||||
var $focusedElementBeforeModal,
|
||||
focusableElementsString = 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]';
|
||||
|
||||
var reassignTabIndexesAndAriaHidden = function(focusableElementsFilterString, closeButtonId, modalId, mainPageId) {
|
||||
// Sets appropriate elements to tab indexable and properly sets aria_hidden on content outside of modal
|
||||
// "focusableElementsFilterString" is a string that indicates all elements that should be focusable
|
||||
// "closeButtonId" is the selector for the button that closes out the modal.
|
||||
// "modalId" is the selector for the modal being managed
|
||||
// "mainPageId" is the selector for the main part of the page
|
||||
// Returns a list of focusableItems
|
||||
var focusableItems;
|
||||
|
||||
$(mainPageId).attr('aria-hidden', 'true');
|
||||
$(modalId).attr('aria-hidden', 'false');
|
||||
|
||||
focusableItems = $(modalId).find('*')
|
||||
.filter(focusableElementsFilterString)
|
||||
.filter(':visible');
|
||||
|
||||
focusableItems.attr('tabindex', '2');
|
||||
$(closeButtonId).attr('tabindex', '1').focus();
|
||||
|
||||
return focusableItems;
|
||||
};
|
||||
|
||||
var trapTabFocus = function(focusableItems, closeButtonId) {
|
||||
// Determines last element in modal and traps focus by causing tab
|
||||
// to focus on the first modal element (close button)
|
||||
// "focusableItems" all elements in the modal that are focusable
|
||||
// "closeButtonId" is the selector for the button that closes out the modal.
|
||||
// returns the last focusable element in the modal.
|
||||
var $last;
|
||||
if (focusableItems.length !== 0) {
|
||||
$last = focusableItems.last();
|
||||
} else {
|
||||
$last = $(closeButtonId);
|
||||
}
|
||||
|
||||
// tab on last element in modal returns to the first one
|
||||
$last.on('keydown', function(e) {
|
||||
var keyCode = e.keyCode || e.which;
|
||||
// 9 is the js keycode for tab
|
||||
if (!e.shiftKey && keyCode === 9) {
|
||||
e.preventDefault();
|
||||
$(closeButtonId).focus();
|
||||
}
|
||||
});
|
||||
|
||||
return $last;
|
||||
};
|
||||
|
||||
var trapShiftTabFocus = function($last, closeButtonId) {
|
||||
$(closeButtonId).on('keydown', function(e) {
|
||||
var keyCode = e.keyCode || e.which;
|
||||
// 9 is the js keycode for tab
|
||||
if (e.shiftKey && keyCode === 9) {
|
||||
e.preventDefault();
|
||||
$last.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var bindReturnFocusListener = function($previouslyFocusedElement, closeButtonId, modalId, mainPageId) {
|
||||
// Ensures that on modal close, focus is returned to the element
|
||||
// that had focus before the modal was opened.
|
||||
$('#lean_overlay, ' + closeButtonId).click(function() {
|
||||
$(mainPageId).attr('aria-hidden', 'false');
|
||||
$(modalId).attr('aria-hidden', 'true');
|
||||
$previouslyFocusedElement.focus();
|
||||
});
|
||||
};
|
||||
|
||||
var bindEscapeKeyListener = function(modalId, closeButtonId) {
|
||||
$(modalId).on('keydown', function(e) {
|
||||
var keyCode = e.keyCode || e.which;
|
||||
// 27 is the javascript keycode for the ESC key
|
||||
if (keyCode === 27) {
|
||||
e.preventDefault();
|
||||
$(closeButtonId).click();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var trapFocusForAccessibleModal = function(
|
||||
$previouslyFocusedElement,
|
||||
focusableElementsFilterString,
|
||||
closeButtonId,
|
||||
modalId,
|
||||
mainPageId) {
|
||||
// Re assess the page for which items internal to the modal should be focusable,
|
||||
// Should be called after the content of the accessible_modal is changed in order
|
||||
// to ensure that the correct elements are accessible.
|
||||
var focusableItems, $last;
|
||||
focusableItems = reassignTabIndexesAndAriaHidden(
|
||||
focusableElementsFilterString,
|
||||
closeButtonId,
|
||||
modalId,
|
||||
mainPageId
|
||||
);
|
||||
$last = trapTabFocus(focusableItems, closeButtonId);
|
||||
trapShiftTabFocus($last, closeButtonId);
|
||||
bindReturnFocusListener($previouslyFocusedElement, closeButtonId, modalId, mainPageId);
|
||||
bindEscapeKeyListener(modalId, closeButtonId);
|
||||
};
|
||||
|
||||
var accessible_modal = function(trigger, closeButtonId, modalId, mainPageId) {
|
||||
// Modifies a lean modal to optimize focus management.
|
||||
@@ -47,71 +151,23 @@ var accessible_modal = function(trigger, closeButtonId, modalId, mainPageId) {
|
||||
// see http://accessibility.oit.ncsu.edu/blog/2013/09/13/the-incredible-accessible-modal-dialog/
|
||||
// for more information on managing modals
|
||||
//
|
||||
var focusableElementsString = 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]';
|
||||
|
||||
var initialFocus
|
||||
$(trigger).click(function() {
|
||||
$focusedElementBeforeModal = $(trigger);
|
||||
|
||||
// when modal is opened, adjust tabindexes and aria-hidden attributes
|
||||
$(mainPageId).attr('aria-hidden', 'true');
|
||||
$(modalId).attr('aria-hidden', 'false');
|
||||
trapFocusForAccessibleModal(
|
||||
$focusedElementBeforeModal,
|
||||
focusableElementsString,
|
||||
closeButtonId,
|
||||
modalId,
|
||||
mainPageId
|
||||
);
|
||||
|
||||
var focusableItems = $(modalId).find('*').filter(focusableElementsString).filter(':visible');
|
||||
|
||||
focusableItems.attr('tabindex', '2');
|
||||
$(closeButtonId).attr('tabindex', '1');
|
||||
$(closeButtonId).focus();
|
||||
|
||||
// define the last tabbable element to complete tab cycle
|
||||
var $last;
|
||||
if (focusableItems.length !== 0) {
|
||||
$last = focusableItems.last();
|
||||
} else {
|
||||
$last = $(closeButtonId);
|
||||
}
|
||||
|
||||
// tab on last element in modal returns to the first one
|
||||
$last.on('keydown', function(e) {
|
||||
var keyCode = e.keyCode || e.which;
|
||||
// 9 is the js keycode for tab
|
||||
if (!e.shiftKey && keyCode === 9) {
|
||||
e.preventDefault();
|
||||
$(closeButtonId).focus();
|
||||
}
|
||||
});
|
||||
|
||||
// shift+tab on first element in modal returns to the last one
|
||||
$(closeButtonId).on('keydown', function(e) {
|
||||
var keyCode = e.keyCode || e.which;
|
||||
// 9 is the js keycode for tab
|
||||
if (e.shiftKey && keyCode == 9) {
|
||||
e.preventDefault();
|
||||
$last.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// manage aria-hidden attrs, return focus to trigger on close
|
||||
$('#lean_overlay, ' + closeButtonId).click(function() {
|
||||
$(mainPageId).attr('aria-hidden', 'false');
|
||||
$(modalId).attr('aria-hidden', 'true');
|
||||
$focusedElementBeforeModal.focus();
|
||||
});
|
||||
|
||||
// get modal to exit on escape key
|
||||
$(modalId).on('keydown', function(e) {
|
||||
var keyCode = e.keyCode || e.which;
|
||||
// 27 is the javascript keycode for the ESC key
|
||||
if (keyCode === 27) {
|
||||
e.preventDefault();
|
||||
$(closeButtonId).click();
|
||||
}
|
||||
});
|
||||
|
||||
// In IE, focus shifts to iframes when they load.
|
||||
// These lines ensure that focus is shifted back to the close button
|
||||
// in the case that a modal that contains an iframe is opened in IE.
|
||||
// see http://stackoverflow.com/questions/15792620/how-to-get-focus-back-for-parent-window-from-an-iframe-programmatically-in-javas
|
||||
var initialFocus = true;
|
||||
// In IE, focus shifts to iframes when they load.
|
||||
// These lines ensure that focus is shifted back to the close button
|
||||
// in the case that a modal that contains an iframe is opened in IE.
|
||||
// see http://stackoverflow.com/questions/15792620/
|
||||
initialFocus = true;
|
||||
$(modalId).find('iframe').on('focus', function() {
|
||||
if (initialFocus) {
|
||||
$(closeButtonId).focus();
|
||||
@@ -133,8 +189,9 @@ $('.nav-skip').click(function() {
|
||||
});
|
||||
// and for the enter key
|
||||
$('.nav-skip').keypress(function(e) {
|
||||
if (e.which == 13) {
|
||||
var href = $(this).attr('href');
|
||||
var href;
|
||||
if (e.which === 13) {
|
||||
href = $(this).attr('href');
|
||||
if (href) {
|
||||
$(href).attr('tabIndex', -1).focus();
|
||||
}
|
||||
|
||||
@@ -22,11 +22,15 @@ class EntitlementUnenrollmentView extends Backbone.View {
|
||||
this.triggerSelector = '.js-entitlement-action-unenroll';
|
||||
this.mainPageSelector = '#dashboard-main';
|
||||
this.genericErrorMsg = gettext('Your unenrollment request could not be processed. Please try again later.');
|
||||
this.modalId = `#${this.$el.attr('id')}`;
|
||||
|
||||
this.dashboardPath = options.dashboardPath;
|
||||
this.signInPath = options.signInPath;
|
||||
this.browseCourses = options.browseCourses;
|
||||
this.isEdx = options.isEdx;
|
||||
|
||||
this.$submitButton = $(this.submitButtonSelector);
|
||||
this.$closeButton = $(this.closeButtonSelector);
|
||||
this.$headerText = $(this.headerTextSelector);
|
||||
this.$errorText = $(this.errorTextSelector);
|
||||
|
||||
@@ -37,6 +41,7 @@ class EntitlementUnenrollmentView extends Backbone.View {
|
||||
|
||||
$trigger.on('click', view.handleTrigger.bind(view));
|
||||
|
||||
// From accessibility_tools.js
|
||||
if (window.accessible_modal) {
|
||||
window.accessible_modal(
|
||||
`#${$trigger.attr('id')}`,
|
||||
@@ -54,6 +59,8 @@ class EntitlementUnenrollmentView extends Backbone.View {
|
||||
const courseNumber = $trigger.data('courseNumber');
|
||||
const apiEndpoint = $trigger.data('entitlementApiEndpoint');
|
||||
|
||||
this.$previouslyFocusedElement = $trigger;
|
||||
|
||||
this.resetModal();
|
||||
this.setHeaderText(courseName, courseNumber);
|
||||
this.setSubmitData(apiEndpoint);
|
||||
@@ -113,12 +120,62 @@ class EntitlementUnenrollmentView extends Backbone.View {
|
||||
this.$submitButton.data('entitlementApiEndpoint', apiEndpoint);
|
||||
}
|
||||
|
||||
switchToSlideOne() {
|
||||
// Randomize survey option order
|
||||
const survey = document.querySelector('.options');
|
||||
for (let i = survey.children.length - 1; i >= 0; i -= 1) {
|
||||
survey.appendChild(survey.children[Math.trunc(Math.random() * i)]);
|
||||
}
|
||||
this.$('.entitlement-unenrollment-modal-inner-wrapper header').addClass('hidden');
|
||||
this.$('.entitlement-unenrollment-modal-submit-wrapper').addClass('hidden');
|
||||
this.$('.slide1').removeClass('hidden');
|
||||
|
||||
// From accessibility_tools.js
|
||||
window.trapFocusForAccessibleModal(
|
||||
this.$previouslyFocusedElement,
|
||||
window.focusableElementsString,
|
||||
this.closeButtonSelector,
|
||||
this.modalId,
|
||||
this.mainPageSelector,
|
||||
);
|
||||
}
|
||||
|
||||
switchToSlideTwo() {
|
||||
let reason = this.$(".reasons_survey input[name='reason']:checked").attr('val');
|
||||
if (reason === 'Other') {
|
||||
reason = this.$('.other_text').val();
|
||||
}
|
||||
if (reason) {
|
||||
window.analytics.track('entitlement_unenrollment_reason.selected', {
|
||||
category: 'user-engagement',
|
||||
label: reason,
|
||||
displayName: 'v1',
|
||||
});
|
||||
}
|
||||
this.$('.slide1').addClass('hidden');
|
||||
this.$('.slide2').removeClass('hidden');
|
||||
|
||||
// From accessibility_tools.js
|
||||
window.trapFocusForAccessibleModal(
|
||||
this.$previouslyFocusedElement,
|
||||
window.focusableElementsString,
|
||||
this.closeButtonSelector,
|
||||
this.modalId,
|
||||
this.mainPageSelector,
|
||||
);
|
||||
}
|
||||
|
||||
onComplete(xhr) {
|
||||
const status = xhr.status;
|
||||
const message = xhr.responseJSON && xhr.responseJSON.detail;
|
||||
|
||||
if (status === 204) {
|
||||
EntitlementUnenrollmentView.redirectTo(this.dashboardPath);
|
||||
if (this.isEdx) {
|
||||
this.switchToSlideOne();
|
||||
this.$('.reasons_survey:first .submit-reasons').click(this.switchToSlideTwo.bind(this));
|
||||
} else {
|
||||
EntitlementUnenrollmentView.redirectTo(this.dashboardPath);
|
||||
}
|
||||
} else if (status === 401 && message === 'Authentication credentials were not provided.') {
|
||||
EntitlementUnenrollmentView.redirectTo(`${this.signInPath}?next=${encodeURIComponent(this.dashboardPath)}`);
|
||||
} else {
|
||||
|
||||
@@ -66,7 +66,9 @@ from student.models import CourseEnrollment
|
||||
$(document).ready(function() {
|
||||
EntitlementUnenrollmentFactory({
|
||||
dashboardPath: "${reverse('dashboard') | n, js_escaped_string}",
|
||||
signInPath: "${reverse('signin_user') | n, js_escaped_string}"
|
||||
signInPath: "${reverse('signin_user') | n, js_escaped_string}",
|
||||
browseCourses: "${marketing_link('COURSES') | n, js_escaped_string}",
|
||||
isEdx: false
|
||||
});
|
||||
});
|
||||
</%static:webpack>
|
||||
|
||||
35
lms/templates/dashboard/_entitlement_reason_survey.html
Normal file
35
lms/templates/dashboard/_entitlement_reason_survey.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<div class="reasons_survey">
|
||||
<div class="slide1 hidden">
|
||||
<h3>${_("We're sorry to see you go! Please share your main reason for unenrolling.")}</h3><br>
|
||||
<ul class="options">
|
||||
<li><label class="option" for="browseEntitlementUnenrollmentOption"><input id="browseEntitlementUnenrollmentOption" type="radio" name="reason" val="I just wanted to browse the material">${_('I just wanted to browse the material')}</label></li>
|
||||
<li><label class="option" for="goalsEntitlementUnenrollmentOption"><input id="goalsEntitlementUnenrollmentOption" type="radio" name="reason" val="This won’t help me reach my goals">${_("This won't help me reach my goals")}</label></li>
|
||||
<li><label class="option" for="timeEntitlementUnenrollmentOption"><input id="timeEntitlementUnenrollmentOption" type="radio" name="reason" val="I don't have the time">${_("I don't have the time")}</label></li>
|
||||
<li><label class="option" for="prerequisitesEntitlementUnenrollmentOption"><input id="prerequisitesEntitlementUnenrollmentOption" type="radio" name="reason" val="I don’t have the academic or language prerequisites">${_("I don't have the academic or language prerequisites")}</label></li>
|
||||
<li><label class="option" for="supportEntitlementUnenrollmentOption"><input id="supportEntitlementUnenrollmentOption" type="radio" name="reason" val="I don't have enough support">${_("I don't have enough support")}</label></li>
|
||||
<li><label class="option" for="qualityEntitlementUnenrollmentOption"><input id="qualityEntitlementUnenrollmentOption" type="radio" name="reason" val="I am not happy with the quality of the content">${_('I am not happy with the quality of the content')}</label></li>
|
||||
<li><label class="option" for="hardEntitlementUnenrollmentOption"><input id="hardEntitlementUnenrollmentOption" type="radio" name="reason" val="The course material was too hard">${_('The course material was too hard')}</label></li>
|
||||
<li><label class="option" for="easyEntitlementUnenrollmentOption"><input id="easyEntitlementUnenrollmentOption" type="radio" name="reason" val="The course material was too easy">${_('The course material was too easy')}</label></li>
|
||||
<li><label class="option" for="brokenEntitlementUnenrollmentOption"><input id="brokenEntitlementUnenrollmentOption" type="radio" name="reason" val="Something was broken">${_('Something was broken')}</label></li>
|
||||
<li><label class="option" for="otherEntitlementUnenrollmentOption"><input id="otherEntitlementUnenrollmentOption" class="other_radio" type="radio" name="reason" val="Other">${_('Other')} <input type="text" class="other_text"/></label></li>
|
||||
</ul>
|
||||
<button class="submit-reasons">${_('Submit')}</button>
|
||||
</div>
|
||||
<div class="slide2 hidden">
|
||||
${_('Thank you for sharing your reasons for unenrolling.')}<br>
|
||||
${_('You are unenrolled from')} <span class="survey_course_name"></span>.
|
||||
|
||||
<div>
|
||||
<a href="/dashboard" class="btn button survey_button return_to_dashboard">
|
||||
${_('Return To Dashboard')}
|
||||
</a>
|
||||
<a href="/courses" class="btn button survey_button browse_courses">
|
||||
${_('Browse Courses')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +73,9 @@ from student.models import CourseEnrollment
|
||||
$(document).ready(function() {
|
||||
EntitlementUnenrollmentFactory({
|
||||
dashboardPath: "${reverse('dashboard') | n, js_escaped_string}",
|
||||
signInPath: "${reverse('signin_user') | n, js_escaped_string}"
|
||||
signInPath: "${reverse('signin_user') | n, js_escaped_string}",
|
||||
browseCourses: "${marketing_link('COURSES') | n, js_escaped_string}",
|
||||
isEdx: true
|
||||
});
|
||||
});
|
||||
</%static:webpack>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<%page expression_filter="h"/>
|
||||
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
|
||||
<div id="entitlement-unenrollment-modal" class="entitlement-unenrollment-modal js-entitlement-unenrollment-modal js-modal" aria-hidden="true">
|
||||
<div class="entitlement-unenrollment-modal-inner-wrapper" role="dialog" aria-modal="true" aria-labelledby="entitlement-unenrollment-modal-title" aria-live="polite">
|
||||
<button class="entitlement-unenrollment-modal-close-btn js-entitlement-unenrollment-modal-close-btn">
|
||||
<span class="icon fa fa-remove" aria-hidden="true"></span>
|
||||
<span class="sr">
|
||||
## Translators: this is a control to allow users to exit out of this modal interface (a menu or piece of UI that takes the full focus of the screen)
|
||||
${_("Close")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<header class="entitlement-unenrollment-modal-header">
|
||||
<h2 id="entitlement-unenrollment-modal-title">
|
||||
<span class='js-entitlement-unenrollment-modal-header-text'></span>
|
||||
<span class="sr">,
|
||||
## Translators: this text gives status on if the modal interface (a menu or piece of UI that takes the full focus of the screen) is open or not
|
||||
${_("window open")}
|
||||
</span>
|
||||
</h2>
|
||||
<hr/>
|
||||
</header>
|
||||
<div class="entitlement-unenrollment-modal-error-text js-entitlement-unenrollment-modal-error-text"></div>
|
||||
<div class="entitlement-unenrollment-modal-submit-wrapper">
|
||||
<button class="entitlement-unenrollment-modal-submit js-entitlement-unenrollment-modal-submit">${_("Unenroll")}</button>
|
||||
</div>
|
||||
<%include file='_entitlement_reason_survey.html' />
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user