Merge pull request #5502 from edx/will/per-course-donation-button
Add donation button to the enrollment success message
This commit is contained in:
243
lms/static/js/dashboard/donation.js
Normal file
243
lms/static/js/dashboard/donation.js
Normal file
@@ -0,0 +1,243 @@
|
||||
var edx = edx || {};
|
||||
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
edx.dashboard = edx.dashboard || {};
|
||||
edx.dashboard.donation = {};
|
||||
|
||||
/**
|
||||
* View for making donations for a course.
|
||||
* @constructor
|
||||
* @param {Object} params
|
||||
* @param {Object} params.el - The container element.
|
||||
* @param {string} params.course - The ID of the course for the donation.
|
||||
*/
|
||||
edx.dashboard.donation.DonationView = function(params) {
|
||||
/**
|
||||
* Dynamically configure a form, which the client can submit
|
||||
* to the payment processor.
|
||||
* @param {Object} form - The form to modify.
|
||||
* @param {string} method - The HTTP method used to submit the form.
|
||||
* @param {string} url - The URL where the form data will be submitted.
|
||||
* @param {Object} params - Form data, included as hidden inputs.
|
||||
*/
|
||||
var configureForm = function(form, method, url, params) {
|
||||
$("input", form).remove();
|
||||
form.attr("action", url);
|
||||
form.attr("method", method);
|
||||
_.each(params, function(value, key) {
|
||||
$("<input>").attr({
|
||||
type: "hidden",
|
||||
name: key,
|
||||
value: value
|
||||
}).appendTo(form);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fire an analytics event indicating that the user
|
||||
* is about to be sent to the external payment processor.
|
||||
*
|
||||
* @param {string} course - The course ID for the donation.
|
||||
*/
|
||||
var firePaymentAnalyticsEvent = function(course) {
|
||||
analytics.track(
|
||||
"edx.bi.user.payment_processor.visited",
|
||||
{
|
||||
category: "donations",
|
||||
label: course
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a donation to the user's cart.
|
||||
*
|
||||
* @param {string} amount - The amount of the donation (e.g. "23.45")
|
||||
* @param {string} course - The ID of the course.
|
||||
* @returns {Object} The promise from the AJAX call to the server,
|
||||
* which resolves with a data object of the form
|
||||
* { payment_url: <string>, payment_params: <Object> }
|
||||
*/
|
||||
var addDonationToCart = function(amount, course) {
|
||||
return $.ajax({
|
||||
url: "/shoppingcart/donation/",
|
||||
type: "POST",
|
||||
data: {
|
||||
amount: amount,
|
||||
course_id: course
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var view = {
|
||||
/**
|
||||
* Initialize the view.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {JQuery selector} params.el - The container element.
|
||||
* @param {string} params.course - The ID of the course for the donation.
|
||||
* @returns {DonationView}
|
||||
*/
|
||||
initialize: function(params) {
|
||||
this.$el = params.el;
|
||||
this.course = params.course;
|
||||
_.bindAll(view,
|
||||
'render', 'donate', 'startPayment',
|
||||
'validate', 'startPayment',
|
||||
'displayServerError', 'submitPaymentForm'
|
||||
);
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the form for making a donation for a course.
|
||||
*
|
||||
* @returns {DonationView}
|
||||
*/
|
||||
render: function() {
|
||||
var html = _.template($("#donation-tpl").html(), {});
|
||||
this.$el.html(html);
|
||||
this.$amount = $("input[name=\"amount\"]", this.$el);
|
||||
this.$submit = $("input[type=\"submit\"]", this.$el);
|
||||
this.$errorMsg = $(".payment-form", this.$el);
|
||||
this.$paymentForm = $(".payment-form", this.$el);
|
||||
this.$submit.click(this.donate);
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a click event on the "donate" button.
|
||||
* This will contact the LMS server to add the donation
|
||||
* to the user's cart, then send the user to the
|
||||
* external payment processor.
|
||||
*
|
||||
* @param {Object} event - The click event.
|
||||
*/
|
||||
donate: function(event) {
|
||||
// Prevent form submission
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Immediately disable the submit button to prevent duplicate submissions
|
||||
this.$submit.addClass("disabled");
|
||||
|
||||
if (this.validate()) {
|
||||
var amount = this.$amount.val();
|
||||
addDonationToCart(amount, this.course)
|
||||
.done(this.startPayment)
|
||||
.fail(this.displayServerError);
|
||||
}
|
||||
else {
|
||||
// If an error occurred, allow the user to resubmit
|
||||
this.$submit.removeClass("disabled");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send signed payment parameters to the external
|
||||
* payment processor.
|
||||
*
|
||||
* @param {Object} data - The signed payment data received from the LMS server.
|
||||
* @param {string} data.payment_url - The URL of the external payment processor.
|
||||
* @param {Object} data.payment_data - Parameters to send to the external payment processor.
|
||||
*/
|
||||
startPayment: function(data) {
|
||||
configureForm(
|
||||
this.$paymentForm,
|
||||
'post',
|
||||
data.payment_url,
|
||||
data.payment_params
|
||||
);
|
||||
firePaymentAnalyticsEvent(this.course);
|
||||
this.submitPaymentForm(this.$paymentForm);
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate the donation amount and mark any validation errors.
|
||||
*
|
||||
* @returns {boolean} True iff the form is valid.
|
||||
*/
|
||||
validate: function() {
|
||||
var amount = this.$amount.val();
|
||||
var isValid = this.validateAmount(amount);
|
||||
|
||||
if (isValid) {
|
||||
this.$amount.removeClass('validation-error');
|
||||
this.$errorMsg.text("");
|
||||
}
|
||||
|
||||
else {
|
||||
this.$amount.addClass('validation-error');
|
||||
this.$errorMsg.text(
|
||||
gettext("Please enter a valid donation amount.")
|
||||
);
|
||||
}
|
||||
|
||||
return isValid;
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate that the given amount is a valid currency string.
|
||||
*
|
||||
* @param {string} amount
|
||||
* @returns {boolean} True iff the amount is valid.
|
||||
*/
|
||||
validateAmount: function(amount) {
|
||||
var amountRegex = /^\d+.\d{2}$|^\d+$/i;
|
||||
if (!amountRegex.test(amount)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parseFloat(amount) < 0.01) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Display an error message when we receive an error from the LMS server.
|
||||
*/
|
||||
displayServerError: function() {
|
||||
// Display the error message
|
||||
this.$errorMsg.text(gettext("Your donation could not be submitted."));
|
||||
|
||||
// Re-enable the submit button to allow the user to retry
|
||||
this.$submit.removeClass("disabled");
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit the payment from to the external payment processor.
|
||||
* This is a separate function so we can easily stub it out in tests.
|
||||
*
|
||||
* @param {Object} form - The dynamically constructed payment form.
|
||||
*/
|
||||
submitPaymentForm: function(form) {
|
||||
form.submit();
|
||||
},
|
||||
};
|
||||
|
||||
view.initialize(params);
|
||||
return view;
|
||||
};
|
||||
|
||||
$(document).ready(function() {
|
||||
// There may be multiple donation forms on the page
|
||||
// (one for each newly enrolled course).
|
||||
// For each one, create a new donation view to handle
|
||||
// that form, and parameterize it based on the
|
||||
// "data-course" attribute (the course ID).
|
||||
$(".donate-container").each(function() {
|
||||
var container = $(this);
|
||||
var course = container.data("course");
|
||||
var view = new edx.dashboard.donation.DonationView({
|
||||
el: container,
|
||||
course: course
|
||||
}).render();
|
||||
});
|
||||
});
|
||||
|
||||
})(jQuery);
|
||||
1
lms/static/js/fixtures/donation.underscore
Symbolic link
1
lms/static/js/fixtures/donation.underscore
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../templates/dashboard/donation.underscore
|
||||
170
lms/static/js/spec/dashboard/donation.js
Normal file
170
lms/static/js/spec/dashboard/donation.js
Normal file
@@ -0,0 +1,170 @@
|
||||
define(['js/common_helpers/template_helpers', 'js/common_helpers/ajax_helpers', 'js/dashboard/donation'],
|
||||
function(TemplateHelpers, AjaxHelpers) {
|
||||
'use strict';
|
||||
|
||||
describe("edx.dashboard.donation.DonationView", function() {
|
||||
|
||||
var PAYMENT_URL = "https://fake.processor.com/pay/";
|
||||
var PAYMENT_PARAMS = {
|
||||
orderId: "test-order",
|
||||
signature: "abcd1234"
|
||||
};
|
||||
var AMOUNT = "45.67";
|
||||
var COURSE_ID = "edx/DemoX/Demo";
|
||||
|
||||
var view = null;
|
||||
var requests = null;
|
||||
|
||||
beforeEach(function() {
|
||||
setFixtures("<div></div>");
|
||||
TemplateHelpers.installTemplate('templates/dashboard/donation');
|
||||
|
||||
view = new edx.dashboard.donation.DonationView({
|
||||
el: $("#jasmine-fixtures"),
|
||||
course: COURSE_ID
|
||||
}).render();
|
||||
|
||||
// Stub out the actual submission of the payment form
|
||||
// (which would cause the page to reload)
|
||||
// This function gets passed the dynamically constructed
|
||||
// form with signed payment parameters from the LMS server,
|
||||
// so we can verify that the form is constructed correctly.
|
||||
spyOn(view, 'submitPaymentForm').andCallFake(function() {});
|
||||
|
||||
// Stub the analytics event tracker
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track']);
|
||||
});
|
||||
|
||||
it("processes a donation for a course", function() {
|
||||
// Spy on AJAX requests
|
||||
requests = AjaxHelpers.requests(this);
|
||||
|
||||
// Enter a donation amount and proceed to the payment page
|
||||
view.$amount.val(AMOUNT);
|
||||
view.donate();
|
||||
|
||||
// Verify that the client contacts the server to create
|
||||
// the donation item in the shopping cart and receive
|
||||
// the signed payment params.
|
||||
AjaxHelpers.expectRequest(
|
||||
requests, "POST", "/shoppingcart/donation/",
|
||||
$.param({ amount: AMOUNT, course_id: COURSE_ID })
|
||||
);
|
||||
|
||||
// Simulate a response from the server containing the signed
|
||||
// parameters to send to the payment processor
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
payment_url: PAYMENT_URL,
|
||||
payment_params: PAYMENT_PARAMS,
|
||||
});
|
||||
|
||||
// Verify that the payment form has the payment parameters
|
||||
// sent by the LMS server, and that it's targeted at the
|
||||
// correct payment URL.
|
||||
// We stub out the actual submission of the form to avoid
|
||||
// leaving the current page during the test.
|
||||
expect(view.submitPaymentForm).toHaveBeenCalled();
|
||||
var form = view.submitPaymentForm.mostRecentCall.args[0];
|
||||
expect(form.serialize()).toEqual($.param(PAYMENT_PARAMS));
|
||||
expect(form.attr('method')).toEqual("post");
|
||||
expect(form.attr('action')).toEqual(PAYMENT_URL);
|
||||
});
|
||||
|
||||
it("validates the donation amount", function() {
|
||||
var assertValidAmount = function(amount, isValid) {
|
||||
expect(view.validateAmount(amount)).toBe(isValid);
|
||||
};
|
||||
assertValidAmount("", false);
|
||||
assertValidAmount(" ", false);
|
||||
assertValidAmount("abc", false);
|
||||
assertValidAmount("14.", false);
|
||||
assertValidAmount(".1", false);
|
||||
assertValidAmount("-1", false);
|
||||
assertValidAmount("-1.00", false);
|
||||
assertValidAmount("-", false);
|
||||
assertValidAmount("0", false);
|
||||
assertValidAmount("0.00", false);
|
||||
assertValidAmount("00.00", false);
|
||||
assertValidAmount("3", true);
|
||||
assertValidAmount("12.34", true);
|
||||
assertValidAmount("278", true);
|
||||
assertValidAmount("278.91", true);
|
||||
assertValidAmount("0.14", true);
|
||||
});
|
||||
|
||||
it("displays validation errors", function() {
|
||||
// Attempt to submit an invalid donation amount
|
||||
view.$amount.val("");
|
||||
view.donate();
|
||||
|
||||
// Verify that the amount field is marked as having a validation error
|
||||
expect(view.$amount).toHaveClass("validation-error");
|
||||
|
||||
// Verify that the error message appears
|
||||
expect(view.$errorMsg.text()).toEqual("Please enter a valid donation amount.");
|
||||
|
||||
// Expect that the submit button is re-enabled to allow users to submit again
|
||||
expect(view.$submit).not.toHaveClass("disabled");
|
||||
|
||||
// Try again, this time submitting a valid amount
|
||||
view.$amount.val(AMOUNT);
|
||||
view.donate();
|
||||
|
||||
// Expect that the errors are cleared
|
||||
expect(view.$errorMsg.text()).toEqual("");
|
||||
|
||||
// Expect that the submit button is disabled
|
||||
expect(view.$submit).toHaveClass("disabled");
|
||||
});
|
||||
|
||||
it("displays an error when the server cannot be contacted", function() {
|
||||
// Spy on AJAX requests
|
||||
requests = AjaxHelpers.requests(this);
|
||||
|
||||
// Simulate an error from the LMS servers
|
||||
view.donate();
|
||||
AjaxHelpers.respondWithError(requests);
|
||||
|
||||
// Expect that the error is displayed
|
||||
expect(view.$errorMsg.text()).toEqual("Your donation could not be submitted.");
|
||||
|
||||
// Verify that the submit button is re-enabled
|
||||
// so users can try again.
|
||||
expect(view.$submit).not.toHaveClass("disabled");
|
||||
});
|
||||
|
||||
it("disables the submit button once the user donates", function() {
|
||||
// Before we submit, the button should be enabled
|
||||
expect(view.$submit).not.toHaveClass("disabled");
|
||||
|
||||
// Simulate starting a donation
|
||||
// Since we're not simulating the AJAX response, this will block
|
||||
// in the state just after the user kicks off the donation process.
|
||||
view.donate();
|
||||
|
||||
// Verify that the submit button is disabled
|
||||
expect(view.$submit).toHaveClass("disabled");
|
||||
});
|
||||
|
||||
it("sends an analytics event when the user submits a donation", function() {
|
||||
// Simulate the submission to the payment processor
|
||||
// We skip the intermediary steps here by passing in
|
||||
// the payment url and parameters,
|
||||
// which the view would ordinarily retrieve from the LMS server.
|
||||
view.startPayment({
|
||||
payment_url: PAYMENT_URL,
|
||||
payment_params: PAYMENT_PARAMS
|
||||
});
|
||||
|
||||
// Verify that the analytics event was fired
|
||||
expect(window.analytics.track).toHaveBeenCalledWith(
|
||||
"edx.bi.user.payment_processor.visited",
|
||||
{
|
||||
category: "donations",
|
||||
label: COURSE_ID
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -219,6 +219,10 @@
|
||||
exports: 'js/staff_debug_actions',
|
||||
deps: ['gettext']
|
||||
},
|
||||
'js/dashboard/donation.js': {
|
||||
exports: 'js/dashboard/donation',
|
||||
deps: ['jquery', 'underscore', 'gettext']
|
||||
},
|
||||
// Backbone classes loaded explicitly until they are converted to use RequireJS
|
||||
'js/models/cohort': {
|
||||
exports: 'CohortModel',
|
||||
@@ -255,7 +259,8 @@
|
||||
'lms/include/js/spec/views/cohorts_spec.js',
|
||||
'lms/include/js/spec/photocapture_spec.js',
|
||||
'lms/include/js/spec/staff_debug_actions_spec.js',
|
||||
'lms/include/js/spec/views/notification_spec.js'
|
||||
'lms/include/js/spec/views/notification_spec.js',
|
||||
'lms/include/js/spec/dashboard/donation.js',
|
||||
]);
|
||||
|
||||
}).call(this, requirejs, define);
|
||||
|
||||
@@ -71,6 +71,7 @@ spec_paths:
|
||||
#
|
||||
fixture_paths:
|
||||
- templates/instructor/instructor_dashboard_2
|
||||
- templates/dashboard
|
||||
|
||||
requirejs:
|
||||
paths:
|
||||
|
||||
Reference in New Issue
Block a user