ECOM-4904 Move the program editor backbone app to Studio (#12962)

This commit is contained in:
Simon Chen
2016-07-19 19:09:51 -04:00
committed by GitHub
parent 7745e7cdf7
commit 1d768cde7a
65 changed files with 3283 additions and 108 deletions

View File

@@ -0,0 +1,59 @@
define([
'backbone',
'jquery',
'underscore',
'js/programs/utils/constants',
'text!templates/programs/confirm_modal.underscore',
'edx-ui-toolkit/js/utils/html-utils',
'gettext'
],
function( Backbone, $, _, constants, ModalTpl, HtmlUtils ) {
'use strict';
return Backbone.View.extend({
events: {
'click .js-cancel': 'destroy',
'click .js-confirm': 'confirm',
'keydown': 'handleKeydown'
},
tpl: HtmlUtils.template( ModalTpl ),
initialize: function( options ) {
this.$parentEl = $( options.parentEl );
this.callback = options.callback;
this.content = options.content;
this.render();
},
render: function() {
HtmlUtils.setHtml(this.$el, this.tpl( this.content ));
HtmlUtils.setHtml(this.$parentEl, HtmlUtils.HTML(this.$el));
this.postRender();
},
postRender: function() {
this.$el.find('.js-focus-first').focus();
},
confirm: function() {
this.callback();
this.destroy();
},
destroy: function() {
this.undelegateEvents();
this.remove();
this.$parentEl.html('');
},
handleKeydown: function( event ) {
var keyCode = event.keyCode;
if ( keyCode === constants.keyCodes.esc ) {
this.destroy();
}
}
});
}
);

View File

@@ -0,0 +1,204 @@
define([
'backbone',
'backbone.validation',
'jquery',
'underscore',
'js/programs/models/course_model',
'js/programs/models/course_run_model',
'js/programs/models/program_model',
'js/programs/views/course_run_view',
'text!templates/programs/course_details.underscore',
'edx-ui-toolkit/js/utils/html-utils',
'gettext',
'js/programs/utils/validation_config'
],
function( Backbone, BackboneValidation, $, _, CourseModel, CourseRunModel,
ProgramModel, CourseRunView, ListTpl, HtmlUtils ) {
'use strict';
return Backbone.View.extend({
parentEl: '.js-course-list',
className: 'course-details',
events: {
'click .js-remove-course': 'destroy',
'click .js-select-course': 'setCourse',
'click .js-add-course-run': 'addCourseRun'
},
tpl: HtmlUtils.template( ListTpl ),
initialize: function( options ) {
this.model = new CourseModel();
Backbone.Validation.bind( this );
this.$parentEl = $( this.parentEl );
// For managing subViews
this.courseRunViews = [];
this.courseRuns = options.courseRuns;
this.programModel = options.programModel;
if ( options.courseData ) {
this.model.set(options.courseData);
} else {
this.model.set({run_modes: []});
}
// Need a unique value for field ids so using model cid
this.model.set({cid: this.model.cid});
this.model.on('change:run_modes', this.updateRuns, this);
this.render();
},
render: function() {
HtmlUtils.setHtml(this.$el, this.tpl(this.formatData()));
this.$parentEl.append( this.$el );
this.postRender();
},
postRender: function() {
var runs = this.model.get('run_modes');
if ( runs && runs.length > 0 ) {
this.addCourseRuns();
}
},
addCourseRun: function(event) {
var $runsContainer = this.$el.find('.js-course-runs'),
runModel = new CourseRunModel(),
runView;
event.preventDefault();
runModel.set({course_key: undefined});
runView = new CourseRunView({
model: runModel,
courseModel: this.model,
courseRuns: this.courseRuns,
programStatus: this.programModel.get('status'),
$parentEl: $runsContainer
});
this.courseRunViews.push( runView );
},
addCourseRuns: function() {
// Create run views
var runs = this.model.get('run_modes'),
$runsContainer = this.$el.find('.js-course-runs');
_.each( runs, function( run ) {
var runModel = new CourseRunModel(),
runView;
runModel.set(run);
runView = new CourseRunView({
model: runModel,
courseModel: this.model,
courseRuns: this.courseRuns,
programStatus: this.programModel.get('status'),
$parentEl: $runsContainer
});
this.courseRunViews.push( runView );
return runView;
}.bind(this) );
},
addCourseToProgram: function() {
var courseCodes = this.programModel.get('course_codes'),
courseData = this.model.toJSON();
if ( this.programModel.isValid( true ) ) {
// We don't want to save the cid so omit it
courseCodes.push( _.omit(courseData, 'cid') );
this.programModel.patch({ course_codes: courseCodes });
}
},
// Delete this view
destroy: function() {
Backbone.Validation.unbind(this);
this.destroyChildren();
this.undelegateEvents();
this.removeCourseFromProgram();
this.remove();
},
destroyChildren: function() {
var runs = this.courseRunViews;
_.each( runs, function( run ) {
run.removeRun();
});
},
// Format data to be passed to the template
formatData: function() {
var data = $.extend( {},
{ courseRuns: this.courseRuns.models },
_.omit( this.programModel.toJSON(), 'run_modes'),
this.model.toJSON()
);
return data;
},
removeCourseFromProgram: function() {
var courseCodes = this.programModel.get('course_codes'),
key = this.model.get('key'),
name = this.model.get('display_name'),
update = [];
update = _.reject( courseCodes, function(course) {
return course.key === key && course.display_name === name;
});
this.programModel.patch({ course_codes: update });
},
setCourse: function( event ) {
var $form = this.$('.js-course-form'),
title = $form.find('.display-name').val(),
key = $form.find('.course-key').val();
event.preventDefault();
this.model.set({
display_name: title,
key: key,
organization: this.programModel.get('organizations')[0]
});
if ( this.model.isValid(true) ) {
this.addCourseToProgram();
this.updateDOM();
this.addCourseRuns();
}
},
updateDOM: function() {
HtmlUtils.setHtml(this.$el, this.tpl( this.formatData() ) );
},
updateRuns: function() {
var courseCodes = this.programModel.get('course_codes'),
key = this.model.get('key'),
name = this.model.get('display_name'),
index;
if ( this.programModel.isValid( true ) ) {
index = _.findIndex( courseCodes, function(course) {
return course.key === key && course.display_name === name;
});
courseCodes[index] = this.model.toJSON();
this.programModel.patch({ course_codes: courseCodes });
}
}
});
}
);

View File

@@ -0,0 +1,113 @@
define([
'backbone',
'jquery',
'underscore',
'text!templates/programs/course_run.underscore',
'edx-ui-toolkit/js/utils/html-utils'
],
function ( Backbone, $, _, CourseRunTpl, HtmlUtils ) {
'use strict';
return Backbone.View.extend({
events: {
'change .js-course-run-select': 'selectRun',
'click .js-remove-run': 'removeRun'
},
tpl: HtmlUtils.template( CourseRunTpl ),
initialize: function( options ) {
/**
* Need the run model for the template, and the courseModel
* to keep parent view up to date with run changes
*/
this.courseModel = options.courseModel;
this.courseRuns = options.courseRuns;
this.programStatus = options.programStatus;
this.model.on('change', this.render, this);
this.courseRuns.on('update', this.updateDropdown, this);
this.$parentEl = options.$parentEl;
this.render();
},
render: function() {
var data = this.model.attributes;
data.programStatus = this.programStatus;
if ( !!this.courseRuns ) {
data.courseRuns = this.courseRuns.toJSON();
}
HtmlUtils.setHtml(this.$el, this.tpl( data ) );
this.$parentEl.append( this.$el );
},
// Delete this view
destroy: function() {
this.undelegateEvents();
this.remove();
},
// Data returned from courseList API is not the correct format
formatData: function( data ) {
return {
course_key: data.id,
mode_slug: 'verified',
start_date: data.start,
sku: ''
};
},
removeRun: function() {
// Update run_modes array on programModel
var startDate = this.model.get('start_date'),
courseKey = this.model.get('course_key'),
/**
* NB: cloning the array so the model will fire a change event when
* the updated version is saved back to the model
*/
runs = _.clone(this.courseModel.get('run_modes')),
updatedRuns = [];
updatedRuns = _.reject( runs, function( obj ) {
return obj.start_date === startDate &&
obj.course_key === courseKey;
});
this.courseModel.set({
run_modes: updatedRuns
});
this.courseRuns.addRun(courseKey);
this.destroy();
},
selectRun: function(event) {
var id = $(event.currentTarget).val(),
runObj = _.findWhere(this.courseRuns.allRuns, {id: id}),
/**
* NB: cloning the array so the model will fire a change event when
* the updated version is saved back to the model
*/
runs = _.clone(this.courseModel.get('run_modes')),
data = this.formatData(runObj);
this.model.set( data );
runs.push(data);
this.courseModel.set({run_modes: runs});
this.courseRuns.removeRun(id);
},
// If a run has not been selected update the dropdown options
updateDropdown: function() {
if ( !this.model.get('course_key') ) {
this.render();
}
}
});
}
);

View File

@@ -0,0 +1,68 @@
(function() {
'use strict';
define([
'backbone',
'js/programs/router',
'js/programs/utils/api_config'
],
function( Backbone, ProgramRouter, apiConfig ) {
return Backbone.View.extend({
el: '.js-program-admin',
events: {
'click .js-app-click': 'navigate'
},
initialize: function() {
apiConfig.set({
lmsBaseUrl: this.$el.data('lms-base-url'),
programsApiUrl: this.$el.data('programs-api-url'),
authUrl: this.$el.data('auth-url'),
username: this.$el.data('username')
});
this.app = new ProgramRouter({
homeUrl: this.$el.data('home-url')
});
this.app.start();
},
/**
* Navigate to a new page within the app.
*
* Attempts to open the link in a new tab/window behave as the user expects, however the app
* and data will be reloaded in the new tab/window.
*
* @param {Event} event - Event being handled.
* @returns {boolean} - Indicates if event handling succeeded (always true).
*/
navigate: function (event) {
var url = $(event.target).attr('href').replace( this.app.root, '' );
/**
* Handle the cases where the user wants to open the link in a new tab/window.
* event.which === 2 checks for the middle mouse button (https://api.jquery.com/event.which/)
*/
if ( event.ctrlKey || event.shiftKey || event.metaKey || event.which === 2 ) {
return true;
}
// We'll take it from here...
event.preventDefault();
// Process the navigation in the app/router.
if ( url === Backbone.history.getFragment() && url === '' ) {
/**
* Note: We must call the index directly since Backbone
* does not support routing to the same route.
*/
this.app.index();
} else {
this.app.navigate( url, { trigger: true } );
}
}
});
}
);
})();

View File

@@ -0,0 +1,112 @@
define([
'backbone',
'backbone.validation',
'jquery',
'underscore',
'js/programs/models/organizations_model',
'js/programs/models/program_model',
'text!templates/programs/program_creator_form.underscore',
'edx-ui-toolkit/js/utils/html-utils',
'gettext',
'js/programs/utils/validation_config'
],
function ( Backbone, BackboneValidation, $, _, OrganizationsModel, ProgramModel, ListTpl, HtmlUtils ) {
'use strict';
return Backbone.View.extend({
parentEl: '.js-program-admin',
events: {
'click .js-create-program': 'createProgram',
'click .js-abort-view': 'abort'
},
tpl: HtmlUtils.template( ListTpl ),
initialize: function( options ) {
this.$parentEl = $( this.parentEl );
this.model = new ProgramModel();
this.model.on( 'sync', this.saveSuccess, this );
this.model.on( 'error', this.saveError, this );
// Hook up validation.
// See: http://thedersen.com/projects/backbone-validation/#validation-binding.
Backbone.Validation.bind( this );
this.organizations = new OrganizationsModel();
this.organizations.on( 'sync', this.render, this );
this.organizations.fetch();
this.router = options.router;
},
render: function() {
HtmlUtils.setHtml(
this.$el,
this.tpl( {
orgs: this.organizations.get('results')
})
);
HtmlUtils.setHtml(this.$parentEl, HtmlUtils.HTML( this.$el ));
},
abort: function( event ) {
event.preventDefault();
this.router.goHome();
},
createProgram: function( event ) {
var data = this.getData();
event.preventDefault();
this.model.set( data );
// Check if the model is valid before saving. Invalid attributes are looked
// up by name. The corresponding elements receieve an `invalid` class and a
// `data-error` attribute. Both are removed when formerly invalid attributes
// become valid.
// See: http://thedersen.com/projects/backbone-validation/#isvalid.
if ( this.model.isValid(true) ) {
this.model.save();
}
},
destroy: function() {
// Unhook validation.
// See: http://thedersen.com/projects/backbone-validation/#unbinding.
Backbone.Validation.unbind(this);
this.undelegateEvents();
this.remove();
},
getData: function() {
return {
name: this.$el.find( '.program-name' ).val(),
subtitle: this.$el.find( '.program-subtitle' ).val(),
category: this.$el.find( '.program-type' ).val(),
marketing_slug: this.$el.find( '.program-marketing-slug' ).val(),
organizations: [{
key: this.$el.find( '.program-org' ).val()
}]
};
},
goToView: function( uri ) {
Backbone.history.navigate( uri, { trigger: true } );
this.destroy();
},
// TODO: add user messaging to show errors
saveError: function( jqXHR ) {
console.log( 'saveError: ', jqXHR );
},
saveSuccess: function() {
this.goToView( String( this.model.get( 'id' ) ) );
}
});
}
);

View File

@@ -0,0 +1,207 @@
define([
'backbone',
'backbone.validation',
'jquery',
'underscore',
'js/programs/collections/course_runs_collection',
'js/programs/models/program_model',
'js/programs/views/confirm_modal_view',
'js/programs/views/course_details_view',
'text!templates/programs/program_details.underscore',
'edx-ui-toolkit/js/utils/html-utils',
'gettext',
'js/programs/utils/validation_config'
],
function( Backbone, BackboneValidation, $, _, CourseRunsCollection,
ProgramModel, ModalView, CourseView, ListTpl,
HtmlUtils ) {
'use strict';
return Backbone.View.extend({
el: '.js-program-admin',
events: {
'blur .js-inline-edit input': 'checkEdit',
'click .js-add-course': 'addCourse',
'click .js-enable-edit': 'editField',
'click .js-publish-program': 'confirmPublish'
},
tpl: HtmlUtils.template( ListTpl ),
initialize: function() {
Backbone.Validation.bind( this );
this.courseRuns = new CourseRunsCollection([], {
organization: this.model.get('organizations')[0]
});
this.courseRuns.fetch();
this.courseRuns.on('sync', this.setAvailableCourseRuns, this);
this.render();
},
render: function() {
HtmlUtils.setHtml(this.$el, this.tpl( this.model.toJSON() ) );
this.postRender();
},
postRender: function() {
var courses = this.model.get( 'course_codes' );
_.each( courses, function( course ) {
var title = course.key + 'Course';
this[ title ] = new CourseView({
courseRuns: this.courseRuns,
programModel: this.model,
courseData: course
});
}.bind(this) );
// Stop listening to the model sync set when publishing
this.model.off( 'sync' );
},
addCourse: function() {
return new CourseView({
courseRuns: this.courseRuns,
programModel: this.model
});
},
checkEdit: function( event ) {
var $input = $(event.target),
$span = $input.prevAll('.js-model-value'),
$btn = $input.next('.js-enable-edit'),
value = $input.val(),
key = $input.data('field'),
data = {};
data[key] = value;
$input.addClass('is-hidden');
$btn.removeClass('is-hidden');
$span.removeClass('is-hidden');
if ( this.model.get( key ) !== value ) {
this.model.set( data );
if ( this.model.isValid( true ) ) {
this.model.patch( data );
$span.text( value );
}
}
},
/**
* Loads modal that user clicks a confirmation button
* in to publish the course (or they can cancel out of it)
*/
confirmPublish: function( event ) {
event.preventDefault();
/**
* Update validation to make marketing slug required
* Note that because this validation is not required for
* the program creation form and is only happening here
* it makes sense to have the validation at the view level
*/
if ( this.model.isValid( true ) && this.validateMarketingSlug() ) {
this.modalView = new ModalView({
model: this.model,
callback: _.bind( this.publishProgram, this ),
content: this.getModalContent(),
parentEl: '.js-publish-modal',
parentView: this
});
}
},
editField: function( event ) {
/**
* Making the assumption that users can only see
* programs that they have permission to edit
*/
var $btn = $( event.currentTarget ),
$el = $btn.prev( 'input' );
event.preventDefault();
$el.prevAll( '.js-model-value' ).addClass( 'is-hidden' );
$el.removeClass( 'is-hidden' )
.addClass( 'edit' )
.focus();
$btn.addClass( 'is-hidden' );
},
getModalContent: function() {
/* jshint maxlen: 300 */
return {
name: gettext('confirm'),
title: gettext('Publish this program?'),
body: gettext(
'After you publish this program, you cannot add or remove course codes or remove course runs.'
),
cta: {
cancel: gettext('Cancel'),
confirm: gettext('Publish')
}
};
},
publishProgram: function() {
var data = {
status: 'active'
};
this.model.set( data, { silent: true } );
this.model.on( 'sync', this.render, this );
this.model.patch( data );
},
setAvailableCourseRuns: function() {
var allRuns = this.courseRuns.toJSON(),
courses = this.model.get('course_codes'),
selectedRuns,
availableRuns = allRuns;
if (courses.length) {
selectedRuns = _.pluck( courses, 'run_modes' );
selectedRuns = _.flatten( selectedRuns );
}
availableRuns = _.reject(allRuns, function(run) {
var selectedCourseRun = _.findWhere( selectedRuns, {
course_key: run.id,
start_date: run.start
});
return !_.isUndefined(selectedCourseRun);
});
this.courseRuns.set(availableRuns);
},
validateMarketingSlug: function() {
var isValid = false,
$input = {},
$message = {};
if ( this.model.get( 'marketing_slug' ).length > 0 ) {
isValid = true;
} else {
$input = this.$el.find( '#program-marketing-slug' );
$message = $input.siblings( '.field-message' );
// Update DOM
$input.addClass( 'has-error' );
$message.addClass( 'has-error' );
$message.find( '.field-message-content' )
.text( gettext( 'Marketing Slug is required.') );
}
return isValid;
}
});
}
);