Skip to content
Snippets Groups Projects
Commit 919264f5 authored by Michael LoTurco's avatar Michael LoTurco
Browse files

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
parent 7a9e098b
No related merge requests found
......@@ -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');
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;
trapFocusForAccessibleModal(
$focusedElementBeforeModal,
focusableElementsString,
closeButtonId,
modalId,
mainPageId
);
// 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>
......
<%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>
......
<%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>
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment