Commit d7b1f730 by Renzo Lucioni

Update CAT create/edit view to support creation and modification of credit courses

XCOM-511
parent faae1e32
......@@ -120,7 +120,7 @@ define([
*
* Note that audit seats cannot be created, only edited.
*/
creatableSeatTypes: ['honor', 'verified', 'professional'],
creatableSeatTypes: ['honor', 'verified', 'professional', 'credit'],
initialize: function () {
this.get('products').on('change:id_verification_required', this.triggerIdVerified, this);
......@@ -179,28 +179,30 @@ define([
},
/**
* Returns an existing CourseSeat corresponding to the given seat type; or creates a new one,
* if one is not found.
* Returns existing CourseSeats corresponding to the given seat type. If none
* are not found, creates a new one.
*
* @param {String} seatType
* @returns {CourseSeat}
* @returns {CourseSeat[]}
*/
getOrCreateSeat: function (seatType) {
getOrCreateSeats: function (seatType) {
var seatClass,
seat = _.find(this.seats(), function (product) {
// Find the seat with the specific seat type
seats = _.filter(this.seats(), function (product) {
// Find the seats with the specific seat type
return product.getSeatType() === seatType;
});
}),
seat;
if (!seat && _.contains(this.creatableSeatTypes, seatType)) {
if (_.isEmpty(seats) && _.contains(this.creatableSeatTypes, seatType)) {
seatClass = CourseUtils.getCourseSeatModel(seatType);
/*jshint newcap: false */
seat = new seatClass();
/*jshint newcap: true */
this.get('products').add(seat);
seats.push(seat);
}
return seat;
return seats;
},
/**
......
......@@ -10,12 +10,15 @@ define([
expires: null,
id_verification_required: null,
price: 0,
credit_provider: null,
credit_hours: null,
product_class: 'Seat'
},
validation: {
price: {
required: true,
pattern: 'number',
msg: gettext('All course seats must have a price.')
},
product_class: {
......
define([
'models/course_seats/course_seat'
],
function (CourseSeat) {
'use strict';
return CourseSeat.extend({
defaults: _.extend({}, CourseSeat.prototype.defaults,
{
certificate_type: 'credit',
id_verification_required: true,
price: 0,
credit_provider: null,
credit_hours: null
}
),
validation: _.extend({}, CourseSeat.prototype.validation,
{
credit_provider: {
required: true,
msg: gettext('All credit seats must have a credit provider.')
},
credit_hours: {
required: true,
pattern: 'number',
min: 0,
msg: gettext('All credit seats must designate a number of credit hours.')
}
}
)
}, {seatType: 'credit'});
}
);
......@@ -54,9 +54,9 @@ define([
name: attribute,
value: this.get(attribute)
});
delete data[attribute];
}
delete data[attribute];
}, this);
// Restore the timezone component, and output the ISO 8601 format expected by the server.
......
......@@ -17,8 +17,8 @@ define([
var model,
honorSeat = {
id: 9,
url: 'http://ecommerce.local:8002/api/v2/products/9/',
id: 8,
url: 'http://ecommerce.local:8002/api/v2/products/8/',
structure: 'child',
product_class: 'Seat',
title: 'Seat in edX Demonstration Course with honor certificate',
......@@ -41,8 +41,8 @@ define([
is_available_to_buy: true
},
verifiedSeat = {
id: 8,
url: 'http://ecommerce.local:8002/api/v2/products/8/',
id: 9,
url: 'http://ecommerce.local:8002/api/v2/products/9/',
structure: 'child',
product_class: 'Seat',
title: 'Seat in edX Demonstration Course with verified certificate (and ID verification)',
......@@ -64,17 +64,83 @@ define([
],
is_available_to_buy: true
},
creditSeat = {
id: 10,
url: 'http://ecommerce.local:8002/api/v2/products/10/',
structure: 'child',
product_class: 'Seat',
title: 'Seat in edX Demonstration Course with credit certificate (and ID verification)',
price: '200.00',
expires: null,
attribute_values: [
{
name: 'certificate_type',
value: 'credit'
},
{
name: 'course_key',
value: 'edX/DemoX/Demo_Course'
},
{
name: 'id_verification_required',
value: true
},
{
name: 'credit_provider',
value: 'Harvard'
},
{
name: 'credit_hours',
value: 1
}
],
is_available_to_buy: true
},
alternateCreditSeat = {
id: 11,
url: 'http://ecommerce.local:8002/api/v2/products/11/',
structure: 'child',
product_class: 'Seat',
title: 'Seat in edX Demonstration Course with credit certificate (and ID verification)',
price: '300.00',
expires: null,
attribute_values: [
{
name: 'certificate_type',
value: 'credit'
},
{
name: 'course_key',
value: 'edX/DemoX/Demo_Course'
},
{
name: 'id_verification_required',
value: true
},
{
name: 'credit_provider',
value: 'MIT'
},
{
name: 'credit_hours',
value: 2
}
],
is_available_to_buy: true
},
data = {
id: 'edX/DemoX/Demo_Course',
url: 'http://ecommerce.local:8002/api/v2/courses/edX/DemoX/Demo_Course/',
name: 'edX Demonstration Course',
verification_deadline: '2015-10-01T00:00:00Z',
type: 'verified',
type: 'credit',
products_url: 'http://ecommerce.local:8002/api/v2/courses/edX/DemoX/Demo_Course/products/',
last_edited: '2015-07-27T00:27:23Z',
products: [
honorSeat,
verifiedSeat,
creditSeat,
alternateCreditSeat,
{
id: 7,
url: 'http://ecommerce.local:8002/api/v2/products/7/',
......@@ -111,14 +177,14 @@ define([
// Sanity check to ensure the products were properly parsed
products = model.get('products');
expect(products.length).toEqual(3);
expect(products.length).toEqual(5);
// Remove the parent products
model.removeParentProducts();
// Only the children survived...
expect(products.length).toEqual(2);
expect(products.where({structure: 'child'}).length).toEqual(2);
expect(products.length).toEqual(4);
expect(products.where({structure: 'child'}).length).toEqual(4);
});
});
......@@ -190,31 +256,37 @@ define([
});
});
describe('getOrCreateSeat', function () {
describe('getOrCreateSeats', function () {
it('should return existing seats', function () {
var mapping = {
'honor': honorSeat,
'verified': verifiedSeat
};
'honor': [honorSeat],
'verified': [verifiedSeat],
'credit': [creditSeat, alternateCreditSeat]
},
seats;
_.each(mapping, function (expected, seatType) {
expect(model.getOrCreateSeat(seatType).toJSON()).toEqual(expected);
seats = model.getOrCreateSeats(seatType);
_.each(seats, function (seat) {
expect(expected).toContain(seat.toJSON());
});
});
});
it('should return null if an audit seat does not already exist', function () {
expect(model.getOrCreateSeat('audit')).toBeUndefined();
it('should return an empty array if an audit seat does not already exist', function () {
expect(model.getOrCreateSeats('audit')).toEqual([]);
});
it('should create a new CourseSeat if one does not exist', function () {
var seat;
// Sanity check to confirm a new seat is created later
expect(model.seats().length).toEqual(2);
expect(model.seats().length).toEqual(4);
// A new seat should be created
seat = model.getOrCreateSeat('professional');
expect(model.seats().length).toEqual(3);
seat = model.getOrCreateSeats('professional')[0];
expect(model.seats().length).toEqual(5);
// The new seat's class/type should correspond to the passed in seat type
expect(seat).toEqual(jasmine.any(ProfessionalSeat));
......@@ -229,7 +301,7 @@ define([
describe('verification deadline validation', function () {
it('succeeds if the verification deadline is after the course seats\' expiration dates', function () {
var seat = model.getOrCreateSeat('verified');
var seat = model.getOrCreateSeats('verified')[0];
model.set('verification_deadline', '2016-01-01T00:00:00Z');
seat.set('expires', '2015-01-01T00:00:00Z');
......@@ -238,7 +310,7 @@ define([
});
it('fails if the verification deadline is before the course seats\' expiration dates', function () {
var seat = model.getOrCreateSeat('verified'),
var seat = model.getOrCreateSeats('verified')[0],
msg = 'The verification deadline must occur AFTER the upgrade deadline.';
model.set('verification_deadline', '2014-01-01T00:00:00Z');
seat.set('expires', '2015-01-01T00:00:00Z');
......
define([
'models/course_seats/professional_seat',
'views/course_seat_form_fields/professional_course_seat_form_field_view'
],
function (ProfessionalSeat,
CourseSeatFormFieldView) {
'use strict';
var model, view;
beforeEach(function () {
model = new ProfessionalSeat();
view = new CourseSeatFormFieldView({model: model}).render();
});
describe('professional course seat form field view', function () {
describe('getFieldValue', function () {
it('should return a boolean if the name is id_verification_required', function () {
// NOTE (CCB): Ideally _.each should be used here to loop over an array of Boolean values.
// However, the tests fail when that implementation is used, hence the repeated code.
model.set('id_verification_required', false);
expect(model.get('id_verification_required')).toEqual(false);
expect(view.getFieldValue('id_verification_required')).toEqual(false);
model.set('id_verification_required', true);
expect(model.get('id_verification_required')).toEqual(true);
expect(view.getFieldValue('id_verification_required')).toEqual(true);
});
// NOTE (CCB): This test is flaky (hence it being skipped).
// Occasionally, calls to the parent class fail.
xit('should always return professional if the name is certificate_type', function () {
expect(view.getFieldValue('certificate_type')).toEqual('professional');
});
});
});
}
);
define([
'models/course_seats/verified_seat',
'views/course_seat_form_fields/verified_course_seat_form_field_view'
],
function (VerifiedSeat,
VerifiedCourseSeatFormFieldView) {
'use strict';
var model, view;
beforeEach(function () {
model = new VerifiedSeat();
view = new VerifiedCourseSeatFormFieldView({model: model}).render();
});
describe('verified course seat form field view', function () {
describe('getData', function () {
it('should return the data from the DOM/model', function () {
var data = {
certificate_type: 'verified',
id_verification_required: 'true',
price: '100',
expires: ''
};
expect(view.getData()).toEqual(data);
});
});
});
}
);
......@@ -4,14 +4,16 @@ define([
'models/course_seats/course_seat',
'models/course_seats/honor_seat',
'models/course_seats/professional_seat',
'models/course_seats/verified_seat'
'models/course_seats/verified_seat',
'models/course_seats/credit_seat'
],
function (_,
AuditSeat,
CourseSeat,
HonorSeat,
ProfessionalSeat,
VerifiedSeat) {
VerifiedSeat,
CreditSeat) {
'use strict';
return {
......@@ -27,7 +29,7 @@ define([
* @returns {CourseSeat[]}
*/
getSeatModelMap: _.memoize(function () {
return _.indexBy([AuditSeat, HonorSeat, ProfessionalSeat, VerifiedSeat], 'seatType');
return _.indexBy([AuditSeat, HonorSeat, ProfessionalSeat, VerifiedSeat, CreditSeat], 'seatType');
}),
/**
......@@ -74,7 +76,7 @@ define([
return 'residual';
});
},
}
};
}
);
define([
'backbone',
'backbone.validation',
'moment',
'underscore'],
function (moment,
function (Backbone,
BackboneValidation,
moment,
_) {
'use strict';
......@@ -81,6 +85,43 @@ define([
return _.every(models, function (model) {
return model.isValid(true);
});
},
/**
* Bind the provided view for form validation.
*
* @param {Backbone.View} view
*/
bindValidation: function (view) {
/* istanbul ignore next */
Backbone.Validation.bind(view, {
valid: function (view, attr) {
var $el = view.$el.find('[name=' + attr + ']'),
$group = $el.closest('.form-group'),
$helpBlock = $group.find('.help-block:first'),
className = 'invalid-' + attr,
$msg = $helpBlock.find('.' + className);
$msg.remove();
$group.removeClass('has-error');
$helpBlock.addClass('hidden');
},
invalid: function (view, attr, error) {
var $el = view.$el.find('[name=' + attr + ']'),
$group = $el.closest('.form-group'),
$helpBlock = $group.find('.help-block:first'),
className = 'invalid-' + attr,
$msg = $helpBlock.find('.' + className);
if (_.isEqual($msg.length, 0)) {
$helpBlock.append('<div class="' + className + '">' + error + '</div>');
}
$group.addClass('has-error');
$helpBlock.removeClass('hidden');
}
});
}
};
}
......
......@@ -9,12 +9,14 @@ define([
'moment',
'underscore',
'underscore.string',
'collections/product_collection',
'text!templates/course_form.html',
'text!templates/_course_type_radio_field.html',
'views/course_seat_form_fields/audit_course_seat_form_field_view',
'views/course_seat_form_fields/honor_course_seat_form_field_view',
'views/course_seat_form_fields/verified_course_seat_form_field_view',
'views/course_seat_form_fields/professional_course_seat_form_field_view',
'views/course_seat_form_fields/credit_course_seat_form_field_view',
'views/alert_view',
'utils/course_utils'
],
......@@ -26,12 +28,14 @@ define([
moment,
_,
_s,
ProductCollection,
CourseFormTemplate,
CourseTypeRadioTemplate,
AuditCourseSeatFormFieldView,
HonorCourseSeatFormFieldView,
VerifiedCourseSeatFormFieldView,
ProfessionalCourseSeatFormFieldView,
CreditCourseSeatFormFieldView,
AlertView,
CourseUtils) {
'use strict';
......@@ -85,7 +89,7 @@ define([
type: 'credit',
displayName: gettext('Credit'),
helpText: gettext('Paid certificate track with initial verification and Verified Certificate, ' +
'and option to buy credit')
'and option to purchase credit')
}
},
......@@ -94,7 +98,8 @@ define([
audit: AuditCourseSeatFormFieldView,
honor: HonorCourseSeatFormFieldView,
verified: VerifiedCourseSeatFormFieldView,
professional: ProfessionalCourseSeatFormFieldView
professional: ProfessionalCourseSeatFormFieldView,
credit: CreditCourseSeatFormFieldView
},
events: {
......@@ -188,18 +193,11 @@ define([
break;
}
// TODO Activate credit seat
var index = activeCourseTypes.indexOf('credit');
if (index > -1) {
activeCourseTypes.splice(index, 1);
}
return activeCourseTypes;
},
setLockedCourseType: function () {
this.lockedCourseType = this.model.get('type');
this.renderCourseTypes();
},
render: function () {
......@@ -213,6 +211,9 @@ define([
this.stickit();
// Avoid the need to create this jQuery object every time an alert has to be rendered.
this.$alerts = this.$el.find('.alerts');
return this;
},
......@@ -263,19 +264,25 @@ define([
if (activeSeats.length < 1) {
activeSeats = ['empty'];
} else {
_.each(CourseUtils.orderSeatTypesForDisplay(activeSeats), function (seatType) {
var model,
var seats,
viewClass,
view = this.courseSeatViews[seatType];
if (!view) {
model = this.model.getOrCreateSeat(seatType);
seats = this.model.getOrCreateSeats(seatType);
// seats = new ProductCollection(this.model.getOrCreateSeats(seatType));
viewClass = this.courseSeatViewMappings[seatType];
if (viewClass && model) {
if (viewClass && seats.length > 0) {
/*jshint newcap: false */
view = new viewClass({model: model});
if (_.isEqual(seatType, 'credit')) {
seats = new ProductCollection(seats);
view = new viewClass({collection: seats, course: this.model});
} else {
view = new viewClass({model: seats[0]});
}
/*jshint newcap: true */
view.render();
......@@ -287,7 +294,7 @@ define([
}
// Retrieve these after any new renderings.
$courseSeats = $courseSeatsContainer.find('.course-seat');
$courseSeats = $courseSeatsContainer.find('.row');
// Hide all seats
$courseSeats.hide();
......@@ -307,10 +314,17 @@ define([
*/
renderAlert: function (level, message) {
var view = new AlertView({level: level, title: gettext('Error!'), message: message});
view.render();
this.$el.find('.alerts').append(view.el);
this.$alerts.append(view.el);
this.alertViews.push(view);
$('body').animate({
scrollTop: this.$alerts.offset().top
}, 500);
this.$alerts.focus();
return this;
},
......
......@@ -4,14 +4,16 @@ define([
'backbone.validation',
'backbone.stickit',
'underscore',
'underscore.string'
'underscore.string',
'utils/utils'
],
function ($,
Backbone,
BackboneValidation,
BackboneStickit,
_,
_s) {
_s,
Utils) {
'use strict';
return Backbone.View.extend({
......@@ -35,55 +37,22 @@ define([
},
className: function () {
return 'row course-seat ' + this.seatType;
return 'row ' + this.seatType + ' course-seat';
},
initialize: function () {
/* istanbul ignore next */
Backbone.Validation.bind(this, {
valid: function (view, attr) {
var $el = view.$('[name=' + attr + ']'),
$group = $el.closest('.form-group');
$group.removeClass('has-error');
$group.find('.help-block:first').html('').addClass('hidden');
},
invalid: function (view, attr, error) {
var $el = view.$('[name=' + attr + ']'),
$group = $el.closest('.form-group');
$group.addClass('has-error');
$group.find('.help-block:first').html(error).removeClass('hidden');
}
});
Utils.bindValidation(this);
},
render: function () {
this.$el.html(this.template(this.model.attributes));
this.stickit();
return this;
},
cleanIdVerificationRequired: function (val) {
return _s.toBoolean(val);
},
getFieldValue: function (name) {
return this.$(_s.sprintf('input[name=%s]', name)).val();
},
/***
* Return the input data from the form fields.
*/
getData: function () {
var data = {},
fields = ['certificate_type', 'id_verification_required', 'price', 'expires'];
_.each(fields, function (field) {
data[field] = this.getFieldValue(field);
}, this);
return data;
}
});
}
......
define([
'backbone',
'backbone.stickit',
'text!templates/credit_course_seat_form_field_row.html'
],
function (Backbone,
BackboneStickit,
CreditSeatTableRowTemplate) {
'use strict';
return Backbone.View.extend({
tagName: 'tr',
className: 'course-seat',
template: _.template(CreditSeatTableRowTemplate),
events: {
'click .remove-seat': 'removeSeatTableRow'
},
bindings: {
'input[name=credit_provider]': {
observe: 'credit_provider',
setOptions: {
validate: true
}
},
'input[name=price]': {
observe: 'price',
setOptions: {
validate: true
}
},
'input[name=credit_hours]': {
observe: 'credit_hours',
setOptions: {
validate: true
}
},
'input[name=expires]': 'expires'
},
initialize: function (options) {
this.course = options.course;
this.isRemovable = options.isRemovable;
},
render: function () {
var context = _.extend({}, this.model.attributes, {isRemovable: this.isRemovable});
this.$el.html(this.template(context));
this.stickit();
return this;
},
/**
* Removes the selected row from the seat table.
*/
removeSeatTableRow: function () {
// Remove deleted seat from course product collection.
this.course.get('products').remove(this.model);
this.remove();
}
});
}
);
// jscs:disable requireCapitalizedConstructors
define([
'views/course_seat_form_fields/course_seat_form_field_view',
'views/course_seat_form_fields/credit_course_seat_form_field_row_view',
'text!templates/credit_course_seat_form_field.html',
'utils/course_utils',
'utils/utils'
],
function (CourseSeatFormFieldView,
CreditCourseSeatFormFieldRowView,
FieldTemplate,
CourseUtils,
Utils) {
'use strict';
return CourseSeatFormFieldView.extend({
certificateType: 'credit',
idVerificationRequired: true,
seatType: 'credit',
template: _.template(FieldTemplate),
rowView: CreditCourseSeatFormFieldRowView,
events: {
'click .add-seat': 'addSeatTableRow'
},
className: function () {
return 'row ' + this.seatType;
},
initialize: function (options) {
this.course = options.course;
Utils.bindValidation(this);
},
render: function () {
this.renderSeatTable();
return this;
},
/**
* Renders a table of course seats sharing a common seat type.
*/
renderSeatTable: function () {
var row,
$tableBody,
rows = [];
this.$el.html(this.template());
$tableBody = this.$el.find('tbody');
// Instantiate new Views handling data binding for each Model in the Collection.
this.collection.each( function (seat) {
row = new this.rowView({
model: seat,
isRemovable: false,
course: this.course
});
row.render();
rows.push(row.el);
}, this);
$tableBody.append(rows);
return this;
},
/**
* Adds a new row to the seat table.
*/
addSeatTableRow: function () {
var seatClass = CourseUtils.getCourseSeatModel(this.seatType),
/*jshint newcap: false */
seat = new seatClass(),
/*jshint newcap: true */
row = new this.rowView({
model: seat,
isRemovable: true,
course: this.course
}),
$tableBody = this.$el.find('tbody');
row.render();
$tableBody.append(row.el);
// Add new seat to course product collection.
this.course.get('products').add(seat);
}
});
}
);
define([
'underscore.string',
'views/course_seat_form_fields/verified_course_seat_form_field_view',
'text!templates/professional_course_seat_form_field.html',
'backbone.super'
'text!templates/professional_course_seat_form_field.html'
],
function (_s,
VerifiedCourseSeatFormFieldView,
function (VerifiedCourseSeatFormFieldView,
FieldTemplate) {
'use strict';
......@@ -13,20 +10,7 @@ define([
certificateType: 'professional',
idVerificationRequired: false,
seatType: 'professional',
template: _.template(FieldTemplate),
getFieldValue: function (name) {
var value;
if (name === 'id_verification_required') {
value = this.$('input[name=id_verification_required]:checked').val();
value = _s.toBoolean(value);
} else {
value = this._super(name);
}
return value;
}
template: _.template(FieldTemplate)
});
}
);
<div class="alerts"></div>
<div class="alerts" tabindex="-1" aria-live="polite"></div>
<div class="fields">
<div class="form-group">
......
<div class="col-sm-12">
<div class="seat-type"><%= gettext('Credit') %></div>
</div>
<div class="col-sm-4 col-sm-offset-4 seat-certificate-type">
<%= gettext('Verified Certificate') %>
</div>
<div class="col-md-10">
<input type="hidden" name="certificate_type" value="credit">
<input type="hidden" name="id_verification_required" value="true">
<div class="form-group">
<table class="table table-striped credit-seats">
<thead>
<tr>
<th id="credit-provider-label"><%= gettext('Credit Provider') %></th>
<th id="price-label"><%= gettext('Price (USD)') %></th>
<th id="credit-hours-label"><%= gettext('Credit Hours') %></th>
<th id="expires-label"><%= gettext('Upgrade Deadline') %></th>
<th>
<button class="add-seat btn btn-primary">
<span class="sr"><%= gettext('Add course seat') %></span>
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
</th>
</tr>
</thead>
<tbody></tbody>
</table>
<!-- NOTE: This help-block is here for validation messages. -->
<span class="help-block" aria-live="polite"></span>
</div>
</div>
<div class="col-sm-4 seat-additional-info"></div>
<td class="credit-provider">
<div class="input-group">
<div class="input-group-addon">
<i class="fa fa-building-o" aria-hidden="true"></i>
</div>
<input type="text" class="form-control" id="credit_provider" name="credit_provider"
aria-labelledby="credit-provider-label">
</div>
</td>
<td class="price">
<div class="input-group">
<div class="input-group-addon">$</div>
<input type="number" class="form-control" id="price" name="price" aria-labelledby="price-label"
min="5" step="1" pattern="\d+" value="<%= price %>">
</div>
</td>
<td class="credit-hours">
<div class="input-group">
<div class="input-group-addon">
<i class="fa fa-clock-o" aria-hidden="true"></i>
</div>
<input type="number" class="form-control" id="credit_hours" name="credit_hours"
aria-labelledby="credit-hours-label" min="0" step="1" pattern="\d+">
</div>
</td>
<td class="expires">
<div class="input-group">
<div class="input-group-addon">
<i class="fa fa-calendar" aria-hidden="true"></i>
</div>
<input type="datetime-local" class="form-control" id="expires" name="expires"
aria-labelledby="expires-label">
</div>
</td>
<% if (isRemovable) { %>
<td>
<button class="remove-seat btn btn-danger">
<span class="sr"><%= gettext('Delete course seat') %></span>
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</td>
<% } %>
Markdown is supported
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