ECOM-4904 Move the program editor backbone app to Studio (#12962)
This commit is contained in:
@@ -28,12 +28,11 @@ class ProgramAuthoringView(View):
|
||||
|
||||
if programs_config.is_studio_tab_enabled and request.user.is_staff:
|
||||
return render_to_response('program_authoring.html', {
|
||||
'show_programs_header': programs_config.is_studio_tab_enabled,
|
||||
'authoring_app_config': programs_config.authoring_app_config,
|
||||
'lms_base_url': '//{}/'.format(settings.LMS_BASE),
|
||||
'programs_api_url': programs_config.public_api_url,
|
||||
'programs_token_url': reverse('programs_id_token'),
|
||||
'studio_home_url': reverse('home'),
|
||||
'uses_pattern_library': True
|
||||
})
|
||||
else:
|
||||
raise Http404
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
'underscore.string': 'common/js/vendor/underscore.string',
|
||||
'backbone': 'common/js/vendor/backbone',
|
||||
'backbone-relational': 'js/vendor/backbone-relational.min',
|
||||
'backbone.validation': 'common/js/vendor/backbone-validation-min',
|
||||
'backbone.associations': 'js/vendor/backbone-associations-min',
|
||||
'backbone.paginator': 'common/js/vendor/backbone.paginator',
|
||||
'tinymce': 'js/vendor/tinymce/js/tinymce/tinymce.full.min',
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
'backbone': 'common/js/vendor/backbone',
|
||||
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
|
||||
'backbone.paginator': 'common/js/vendor/backbone.paginator',
|
||||
'backbone.validation': 'common/js/vendor/backbone-validation-min',
|
||||
'backbone-relational': 'xmodule_js/common_static/js/vendor/backbone-relational.min',
|
||||
'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min',
|
||||
'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
|
||||
@@ -267,7 +268,10 @@
|
||||
'js/certificates/spec/views/certificate_details_spec',
|
||||
'js/certificates/spec/views/certificate_editor_spec',
|
||||
'js/certificates/spec/views/certificates_list_spec',
|
||||
'js/certificates/spec/views/certificate_preview_spec'
|
||||
'js/certificates/spec/views/certificate_preview_spec',
|
||||
'js/spec/models/auto_auth_model_spec',
|
||||
'js/spec/views/programs/program_creator_spec',
|
||||
'js/spec/views/programs/program_details_spec'
|
||||
];
|
||||
|
||||
i = 0;
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
'backbone': 'common/js/vendor/backbone',
|
||||
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
|
||||
'backbone.paginator': 'common/js/vendor/backbone.paginator',
|
||||
'backbone.validation': 'common/js/vendor/backbone-validation',
|
||||
'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.min',
|
||||
'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
|
||||
'xmodule': 'xmodule_js/src/xmodule',
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
require(["domReady", "jquery", "underscore", "gettext", "common/js/components/views/feedback_notification",
|
||||
"common/js/components/views/feedback_prompt", "js/utils/date_utils",
|
||||
"js/utils/module", "js/utils/handle_iframe_binding", "jquery.ui", "jquery.leanModal",
|
||||
"jquery.form", "jquery.smoothScroll"],
|
||||
function(domReady, $, _, gettext, NotificationView, PromptView, DateUtils, ModuleUtils, IframeUtils)
|
||||
require([
|
||||
"domReady",
|
||||
"jquery",
|
||||
"underscore",
|
||||
"gettext",
|
||||
"common/js/components/views/feedback_notification",
|
||||
"common/js/components/views/feedback_prompt",
|
||||
"js/utils/date_utils",
|
||||
"js/utils/module",
|
||||
"js/utils/handle_iframe_binding",
|
||||
"edx-ui-toolkit/js/dropdown-menu/dropdown-menu-view",
|
||||
"jquery.ui",
|
||||
"jquery.leanModal",
|
||||
"jquery.form",
|
||||
"jquery.smoothScroll"
|
||||
],
|
||||
function(
|
||||
domReady,
|
||||
$,
|
||||
_,
|
||||
gettext,
|
||||
NotificationView,
|
||||
PromptView,
|
||||
DateUtils,
|
||||
ModuleUtils,
|
||||
IframeUtils,
|
||||
DropdownMenuView
|
||||
)
|
||||
{
|
||||
|
||||
var $body;
|
||||
|
||||
domReady(function() {
|
||||
var dropdownMenuView;
|
||||
|
||||
$body = $('body');
|
||||
|
||||
$body.on('click', '.embeddable-xml-input', function() {
|
||||
@@ -67,6 +92,14 @@ domReady(function() {
|
||||
if ($.browser.msie) {
|
||||
$.ajaxSetup({ cache: false });
|
||||
}
|
||||
|
||||
//Initiate the edx tool kit dropdown menu
|
||||
if ($('.js-header-user-menu').length){
|
||||
dropdownMenuView = new DropdownMenuView({
|
||||
el: '.js-header-user-menu'
|
||||
});
|
||||
dropdownMenuView.postRender();
|
||||
}
|
||||
});
|
||||
|
||||
function smoothScrollLink(e) {
|
||||
|
||||
10
cms/static/js/programs/collections/auto_auth_collection.js
Normal file
10
cms/static/js/programs/collections/auto_auth_collection.js
Normal file
@@ -0,0 +1,10 @@
|
||||
define([
|
||||
'backbone',
|
||||
'js/programs/utils/auth_utils'
|
||||
],
|
||||
function( Backbone, auth ) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Collection.extend(auth.autoSync);
|
||||
}
|
||||
);
|
||||
59
cms/static/js/programs/collections/course_runs_collection.js
Normal file
59
cms/static/js/programs/collections/course_runs_collection.js
Normal file
@@ -0,0 +1,59 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/utils/api_config',
|
||||
'js/programs/collections/auto_auth_collection',
|
||||
'jquery.cookie'
|
||||
],
|
||||
function( Backbone, $, apiConfig, AutoAuthCollection ) {
|
||||
'use strict';
|
||||
|
||||
return AutoAuthCollection.extend({
|
||||
allRuns: [],
|
||||
|
||||
initialize: function(models, options) {
|
||||
// Ignore pagination and give me everything
|
||||
var orgStr = options.organization.key,
|
||||
queries = '?org=' + orgStr + '&username=' + apiConfig.get('username') + '&page_size=1000';
|
||||
|
||||
this.url = apiConfig.get('lmsBaseUrl') + 'api/courses/v1/courses/' + queries;
|
||||
},
|
||||
|
||||
/*
|
||||
* Abridged version of Backbone.Collection.Create that does not
|
||||
* save the updated Collection back to the server
|
||||
* (code based on original function - http://backbonejs.org/docs/backbone.html#section-134)
|
||||
*/
|
||||
create: function(model, options) {
|
||||
options = options ? _.clone(options) : {};
|
||||
model = this._prepareModel(model, options);
|
||||
|
||||
if (!!model) {
|
||||
this.add(model, options);
|
||||
return model;
|
||||
}
|
||||
},
|
||||
|
||||
parse: function(data) {
|
||||
this.allRuns = data.results;
|
||||
|
||||
// Because pagination is ignored just set results
|
||||
return data.results;
|
||||
},
|
||||
|
||||
// Adds a run back into the model for selection
|
||||
addRun: function(id) {
|
||||
var courseRun = _.findWhere( this.allRuns, { id: id });
|
||||
|
||||
this.create(courseRun);
|
||||
},
|
||||
|
||||
// Removes a run from the model for selection
|
||||
removeRun: function(id) {
|
||||
var courseRun = this.where({id: id});
|
||||
|
||||
this.remove(courseRun);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
13
cms/static/js/programs/collections/programs_collection.js
Normal file
13
cms/static/js/programs/collections/programs_collection.js
Normal file
@@ -0,0 +1,13 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/models/program_model'
|
||||
],
|
||||
function( Backbone, $, ProgramModel ) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Collection.extend({
|
||||
model: ProgramModel
|
||||
});
|
||||
}
|
||||
);
|
||||
17
cms/static/js/programs/models/api_config_model.js
Normal file
17
cms/static/js/programs/models/api_config_model.js
Normal file
@@ -0,0 +1,17 @@
|
||||
define([
|
||||
'backbone'
|
||||
],
|
||||
function( Backbone ) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Model.extend({
|
||||
defaults: {
|
||||
username: '',
|
||||
lmsBaseUrl: '',
|
||||
programsApiUrl: '',
|
||||
authUrl: '/programs/id_token/',
|
||||
idToken: ''
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
10
cms/static/js/programs/models/auto_auth_model.js
Normal file
10
cms/static/js/programs/models/auto_auth_model.js
Normal file
@@ -0,0 +1,10 @@
|
||||
define([
|
||||
'backbone',
|
||||
'js/programs/utils/auth_utils'
|
||||
],
|
||||
function( Backbone, auth ) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Model.extend(auth.autoSync);
|
||||
}
|
||||
);
|
||||
38
cms/static/js/programs/models/course_model.js
Normal file
38
cms/static/js/programs/models/course_model.js
Normal file
@@ -0,0 +1,38 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/utils/api_config',
|
||||
'js/programs/models/auto_auth_model',
|
||||
'jquery.cookie',
|
||||
'gettext'
|
||||
],
|
||||
function( Backbone, $, apiConfig, AutoAuthModel ) {
|
||||
'use strict';
|
||||
|
||||
return AutoAuthModel.extend({
|
||||
|
||||
validation: {
|
||||
key: {
|
||||
required: true,
|
||||
maxLength: 64
|
||||
},
|
||||
display_name: {
|
||||
required: true,
|
||||
maxLength: 128
|
||||
}
|
||||
},
|
||||
|
||||
labels: {
|
||||
key: gettext('Course Code'),
|
||||
display_name: gettext('Course Title')
|
||||
},
|
||||
|
||||
defaults: {
|
||||
display_name: false,
|
||||
key: false,
|
||||
organization: [],
|
||||
run_modes: []
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
17
cms/static/js/programs/models/course_run_model.js
Normal file
17
cms/static/js/programs/models/course_run_model.js
Normal file
@@ -0,0 +1,17 @@
|
||||
define([
|
||||
'backbone'
|
||||
],
|
||||
function( Backbone ) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Model.extend({
|
||||
defaults: {
|
||||
course_key: '',
|
||||
mode_slug: 'verified',
|
||||
sku: '',
|
||||
start_date: '',
|
||||
run_key: ''
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
16
cms/static/js/programs/models/organizations_model.js
Normal file
16
cms/static/js/programs/models/organizations_model.js
Normal file
@@ -0,0 +1,16 @@
|
||||
define([
|
||||
'js/programs/utils/api_config',
|
||||
'js/programs/models/auto_auth_model'
|
||||
],
|
||||
function( apiConfig, AutoAuthModel ) {
|
||||
'use strict';
|
||||
|
||||
return AutoAuthModel.extend({
|
||||
|
||||
url: function() {
|
||||
return apiConfig.get('programsApiUrl') + 'organizations/?page_size=1000';
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
);
|
||||
106
cms/static/js/programs/models/program_model.js
Normal file
106
cms/static/js/programs/models/program_model.js
Normal file
@@ -0,0 +1,106 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/utils/api_config',
|
||||
'js/programs/models/auto_auth_model',
|
||||
'jquery.cookie'
|
||||
],
|
||||
function( Backbone, $, apiConfig, AutoAuthModel ) {
|
||||
'use strict';
|
||||
|
||||
return AutoAuthModel.extend({
|
||||
|
||||
// Backbone.Validation rules.
|
||||
// See: http://thedersen.com/projects/backbone-validation/#configure-validation-rules-on-the-model.
|
||||
validation: {
|
||||
name: {
|
||||
required: true,
|
||||
maxLength: 255
|
||||
},
|
||||
subtitle: {
|
||||
// The underlying Django model does not require a subtitle.
|
||||
maxLength: 255
|
||||
},
|
||||
category: {
|
||||
required: true,
|
||||
// XSeries is currently the only valid Program type.
|
||||
oneOf: ['xseries']
|
||||
},
|
||||
organizations: 'validateOrganizations',
|
||||
marketing_slug: {
|
||||
maxLength: 255
|
||||
}
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
this.url = apiConfig.get('programsApiUrl') + 'programs/' + this.id + '/';
|
||||
},
|
||||
|
||||
validateOrganizations: function( orgArray ) {
|
||||
/**
|
||||
* The array passed to this method contains a single object representing
|
||||
* the selected organization; the object contains the organization's key.
|
||||
* In the future, multiple organizations might be associated with a program.
|
||||
*/
|
||||
var i,
|
||||
len = orgArray ? orgArray.length : 0;
|
||||
|
||||
for ( i = 0; i < len; i++ ) {
|
||||
if ( orgArray[i].key === 'false' ) {
|
||||
return gettext('Please select a valid organization.');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getConfig: function( options ) {
|
||||
var patch = options && options.patch,
|
||||
params = patch ? this.get('id') + '/' : '',
|
||||
config = _.extend({ validate: true, parse: true }, {
|
||||
type: patch ? 'PATCH' : 'POST',
|
||||
url: apiConfig.get('programsApiUrl') + 'programs/' + params,
|
||||
contentType: patch ? 'application/merge-patch+json' : 'application/json',
|
||||
context: this,
|
||||
// NB: setting context fails in tests
|
||||
success: _.bind( this.saveSuccess, this ),
|
||||
error: _.bind( this.saveError, this )
|
||||
});
|
||||
|
||||
if ( patch ) {
|
||||
config.data = JSON.stringify( options.update ) || this.attributes;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
patch: function( data ) {
|
||||
this.save({
|
||||
patch: true,
|
||||
update: data
|
||||
});
|
||||
},
|
||||
|
||||
save: function( options ) {
|
||||
var method,
|
||||
patch = options && options.patch ? true : false,
|
||||
config = this.getConfig( options );
|
||||
|
||||
/**
|
||||
* Simplified version of code from the default Backbone save function
|
||||
* http://backbonejs.org/docs/backbone.html#section-87
|
||||
*/
|
||||
method = this.isNew() ? 'create' : ( patch ? 'patch' : 'update' );
|
||||
|
||||
this.sync( method, this, config );
|
||||
},
|
||||
|
||||
saveError: function( jqXHR ) {
|
||||
this.trigger( 'error', jqXHR );
|
||||
},
|
||||
|
||||
saveSuccess: function( data ) {
|
||||
this.set({ id: data.id });
|
||||
this.trigger( 'sync', this );
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
10
cms/static/js/programs/program_admin_app.js
Normal file
10
cms/static/js/programs/program_admin_app.js
Normal file
@@ -0,0 +1,10 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
require([
|
||||
'js/programs/views/program_admin_app_view'
|
||||
],
|
||||
function( ProgramAdminAppView ) {
|
||||
return new ProgramAdminAppView();
|
||||
}
|
||||
);
|
||||
})();
|
||||
65
cms/static/js/programs/router.js
Normal file
65
cms/static/js/programs/router.js
Normal file
@@ -0,0 +1,65 @@
|
||||
define([
|
||||
'backbone',
|
||||
'js/programs/views/program_creator_view',
|
||||
'js/programs/views/program_details_view',
|
||||
'js/programs/models/program_model'
|
||||
],
|
||||
function( Backbone, ProgramCreatorView, ProgramDetailsView, ProgramModel ) {
|
||||
'use strict';
|
||||
|
||||
return Backbone.Router.extend({
|
||||
root: '/program/',
|
||||
|
||||
routes: {
|
||||
'new': 'programCreator',
|
||||
':id': 'programDetails'
|
||||
},
|
||||
|
||||
initialize: function( options ) {
|
||||
this.homeUrl = options.homeUrl;
|
||||
},
|
||||
|
||||
goHome: function() {
|
||||
window.location.href = this.homeUrl;
|
||||
},
|
||||
|
||||
loadProgramDetails: function() {
|
||||
this.programDetailsView = new ProgramDetailsView({
|
||||
model: this.programModel
|
||||
});
|
||||
},
|
||||
|
||||
programCreator: function() {
|
||||
if ( this.programCreatorView ) {
|
||||
this.programCreatorView.destroy();
|
||||
}
|
||||
|
||||
this.programCreatorView = new ProgramCreatorView({
|
||||
router: this
|
||||
});
|
||||
},
|
||||
|
||||
programDetails: function( id ) {
|
||||
this.programModel = new ProgramModel({
|
||||
id: id
|
||||
});
|
||||
|
||||
this.programModel.on( 'sync', this.loadProgramDetails, this );
|
||||
this.programModel.fetch();
|
||||
},
|
||||
|
||||
/**
|
||||
* Starts the router.
|
||||
*/
|
||||
start: function () {
|
||||
if ( !Backbone.history.started ) {
|
||||
Backbone.history.start({
|
||||
pushState: true,
|
||||
root: this.root
|
||||
});
|
||||
}
|
||||
return this;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
22
cms/static/js/programs/shims/gettext.js
Normal file
22
cms/static/js/programs/shims/gettext.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* the Programs application loads gettext identity library via django, thus
|
||||
* components reference gettext globally so a shim is added here to reflect
|
||||
* the text so tests can be run if modules reference gettext
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
if ( !window.gettext ) {
|
||||
window.gettext = function (text) {
|
||||
return text;
|
||||
};
|
||||
}
|
||||
|
||||
if ( !window.interpolate ) {
|
||||
window.interpolate = function (text) {
|
||||
return text;
|
||||
};
|
||||
}
|
||||
|
||||
return window;
|
||||
})();
|
||||
21
cms/static/js/programs/utils/api_config.js
Normal file
21
cms/static/js/programs/utils/api_config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
define([
|
||||
'js/programs/models/api_config_model'
|
||||
],
|
||||
function( ApiConfigModel ) {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* This js module implements the Singleton pattern for an instance
|
||||
* of the ApiConfigModel Backbone model. It returns the same shared
|
||||
* instance of that model anywhere it is required.
|
||||
*/
|
||||
var instance;
|
||||
|
||||
if (instance === undefined) {
|
||||
instance = new ApiConfigModel();
|
||||
}
|
||||
|
||||
return instance;
|
||||
|
||||
}
|
||||
);
|
||||
89
cms/static/js/programs/utils/auth_utils.js
Normal file
89
cms/static/js/programs/utils/auth_utils.js
Normal file
@@ -0,0 +1,89 @@
|
||||
define([
|
||||
'jquery',
|
||||
'underscore',
|
||||
'js/programs/utils/api_config'
|
||||
],
|
||||
function( $, _, apiConfig ) {
|
||||
'use strict';
|
||||
|
||||
var auth = {
|
||||
autoSync: {
|
||||
/**
|
||||
* Override Backbone.sync to seamlessly attempt (re-)authentication when necessary.
|
||||
*
|
||||
* If a 401 error response is encountered while making a request to the Programs,
|
||||
* API, this wrapper will attempt to request an id token from a custom endpoint
|
||||
* via AJAX. Then the original request will be retried once more.
|
||||
*
|
||||
* Any other response than 401 on the original API request, or any error occurring
|
||||
* on the retried API request (including 401), will be handled by the base sync
|
||||
* implementation.
|
||||
*
|
||||
*/
|
||||
sync: function( method, model, options ) {
|
||||
|
||||
var oldError = options.error;
|
||||
|
||||
this._setHeaders( options );
|
||||
|
||||
options.notifyOnError = false; // suppress Studio error pop-up that will happen if we get a 401
|
||||
|
||||
options.error = function(xhr, textStatus, errorThrown) {
|
||||
if (xhr && xhr.status === 401) {
|
||||
// attempt auth and retry
|
||||
this._updateToken(function() {
|
||||
// restore the original error handler
|
||||
options.error = oldError;
|
||||
options.notifyOnError = true; // if it fails again, let Studio notify.
|
||||
delete options.xhr; // remove the failed (401) xhr from the last try.
|
||||
|
||||
// update authorization header
|
||||
this._setHeaders( options );
|
||||
|
||||
Backbone.sync.call(this, method, model, options);
|
||||
}.bind(this));
|
||||
} else if (oldError) {
|
||||
// fall back to the original error handler
|
||||
oldError.call(this, xhr, textStatus, errorThrown);
|
||||
}
|
||||
}.bind(this);
|
||||
return Backbone.sync.call(this, method, model, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fix up headers on an imminent AJAX sync, ensuring that the JWT token is enclosed
|
||||
* and that credentials are included when the request is being made cross-domain.
|
||||
*/
|
||||
_setHeaders: function( ajaxOptions ) {
|
||||
ajaxOptions.headers = _.extend ( ajaxOptions.headers || {}, {
|
||||
Authorization: 'JWT ' + apiConfig.get( 'idToken' )
|
||||
});
|
||||
ajaxOptions.xhrFields = _.extend( ajaxOptions.xhrFields || {}, {
|
||||
withCredentials: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a new id token from the configured endpoint, update the api config,
|
||||
* and invoke the specified callback.
|
||||
*/
|
||||
_updateToken: function( success ) {
|
||||
|
||||
$.ajax({
|
||||
url: apiConfig.get('authUrl'),
|
||||
xhrFields: {
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials
|
||||
withCredentials: true
|
||||
},
|
||||
crossDomain: true
|
||||
}).done(function ( data ) {
|
||||
// save the newly-retrieved id token
|
||||
apiConfig.set( 'idToken', data.id_token );
|
||||
}).done( success );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return auth;
|
||||
}
|
||||
);
|
||||
16
cms/static/js/programs/utils/constants.js
Normal file
16
cms/static/js/programs/utils/constants.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Reusable constants
|
||||
*/
|
||||
define([], function() {
|
||||
'use strict';
|
||||
|
||||
return {
|
||||
keyCodes: {
|
||||
tab: 9,
|
||||
enter: 13,
|
||||
esc: 27,
|
||||
up: 38,
|
||||
down: 40
|
||||
}
|
||||
};
|
||||
});
|
||||
70
cms/static/js/programs/utils/validation_config.js
Normal file
70
cms/static/js/programs/utils/validation_config.js
Normal file
@@ -0,0 +1,70 @@
|
||||
define([
|
||||
'backbone',
|
||||
'backbone.validation',
|
||||
'underscore',
|
||||
'gettext'
|
||||
],
|
||||
function( Backbone, BackboneValidation, _ ) {
|
||||
'use strict';
|
||||
|
||||
var errorClass = 'has-error',
|
||||
messageEl = '.field-message',
|
||||
messageContent = '.field-message-content';
|
||||
|
||||
// These are the same messages provided by Backbone.Validation,
|
||||
// marked for translation.
|
||||
// See: http://thedersen.com/projects/backbone-validation/#overriding-the-default-error-messages.
|
||||
_.extend( Backbone.Validation.messages, {
|
||||
required: gettext( '{0} is required' ),
|
||||
acceptance: gettext( '{0} must be accepted' ),
|
||||
min: gettext( '{0} must be greater than or equal to {1}' ),
|
||||
max: gettext( '{0} must be less than or equal to {1}' ),
|
||||
range: gettext( '{0} must be between {1} and {2}' ),
|
||||
length: gettext( '{0} must be {1} characters' ),
|
||||
minLength: gettext( '{0} must be at least {1} characters' ),
|
||||
maxLength: gettext( '{0} must be at most {1} characters' ),
|
||||
rangeLength: gettext( '{0} must be between {1} and {2} characters' ),
|
||||
oneOf: gettext( '{0} must be one of: gettext( {1}' ),
|
||||
equalTo: gettext( '{0} must be the same as {1}' ),
|
||||
digits: gettext( '{0} must only contain digits' ),
|
||||
number: gettext( '{0} must be a number' ),
|
||||
email: gettext( '{0} must be a valid email' ),
|
||||
url: gettext( '{0} must be a valid url' ),
|
||||
inlinePattern: gettext( '{0} is invalid' )
|
||||
});
|
||||
|
||||
_.extend( Backbone.Validation.callbacks, {
|
||||
// Gets called when a previously invalid field in the
|
||||
// view becomes valid. Removes any error message.
|
||||
valid: function( view, attr, selector ) {
|
||||
var $input = view.$( '[' + selector + '~="' + attr + '"]' ),
|
||||
$message = $input.siblings( messageEl );
|
||||
|
||||
$input.removeClass( errorClass )
|
||||
.removeAttr( 'data-error' );
|
||||
|
||||
$message.removeClass( errorClass )
|
||||
.find( messageContent )
|
||||
.text( '' );
|
||||
},
|
||||
|
||||
// Gets called when a field in the view becomes invalid.
|
||||
// Adds a error message.
|
||||
invalid: function( view, attr, error, selector ) {
|
||||
var $input = view.$( '[' + selector + '~="' + attr + '"]' ),
|
||||
$message = $input.siblings( messageEl );
|
||||
|
||||
$input.addClass( errorClass )
|
||||
.attr( 'data-error', error );
|
||||
|
||||
$message.addClass( errorClass )
|
||||
.find( messageContent )
|
||||
.text( $input.data('error') );
|
||||
}
|
||||
});
|
||||
|
||||
Backbone.Validation.configure({
|
||||
labelFormatter: 'label'
|
||||
});
|
||||
}
|
||||
);
|
||||
59
cms/static/js/programs/views/confirm_modal_view.js
Normal file
59
cms/static/js/programs/views/confirm_modal_view.js
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
204
cms/static/js/programs/views/course_details_view.js
Normal file
204
cms/static/js/programs/views/course_details_view.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
113
cms/static/js/programs/views/course_run_view.js
Normal file
113
cms/static/js/programs/views/course_run_view.js
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
68
cms/static/js/programs/views/program_admin_app_view.js
Normal file
68
cms/static/js/programs/views/program_admin_app_view.js
Normal 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 } );
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
})();
|
||||
112
cms/static/js/programs/views/program_creator_view.js
Normal file
112
cms/static/js/programs/views/program_creator_view.js
Normal 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' ) ) );
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
207
cms/static/js/programs/views/program_details_view.js
Normal file
207
cms/static/js/programs/views/program_details_view.js
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
116
cms/static/js/spec/models/auto_auth_model_spec.js
Normal file
116
cms/static/js/spec/models/auto_auth_model_spec.js
Normal file
@@ -0,0 +1,116 @@
|
||||
define([
|
||||
'underscore',
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/utils/api_config',
|
||||
'js/programs/models/auto_auth_model'
|
||||
],
|
||||
function( _, Backbone, $, apiConfig, AutoAuthModel ) {
|
||||
'use strict';
|
||||
|
||||
describe('AutoAuthModel', function () {
|
||||
|
||||
var model,
|
||||
testErrorCallback,
|
||||
fakeAjaxDeferred,
|
||||
spyOnBackboneSync,
|
||||
callSync,
|
||||
checkAuthAttempted,
|
||||
dummyModel = {'dummy': 'model'},
|
||||
authUrl = apiConfig.get( 'authUrl' );
|
||||
|
||||
beforeEach( function() {
|
||||
|
||||
// instance under test
|
||||
model = new AutoAuthModel();
|
||||
|
||||
// stand-in for the error callback a caller might pass with options to Backbone.Model.sync
|
||||
testErrorCallback = jasmine.createSpy();
|
||||
|
||||
fakeAjaxDeferred = $.Deferred();
|
||||
spyOn( $, 'ajax' ).and.returnValue( fakeAjaxDeferred );
|
||||
return fakeAjaxDeferred;
|
||||
|
||||
});
|
||||
|
||||
spyOnBackboneSync = function( status ) {
|
||||
// set up Backbone.sync to invoke its error callback with the desired HTTP status
|
||||
spyOn( Backbone, 'sync' ).and.callFake( function(method, model, options) {
|
||||
var fakeXhr = options.xhr = { status: status };
|
||||
options.error(fakeXhr, 0, '');
|
||||
});
|
||||
};
|
||||
|
||||
callSync = function(options) {
|
||||
var params,
|
||||
syncOptions = _.extend( { error: testErrorCallback }, options || {} );
|
||||
|
||||
model.sync('GET', dummyModel, syncOptions);
|
||||
|
||||
// make sure Backbone.sync was called with custom error handling
|
||||
expect( Backbone.sync.calls.count() ).toEqual(1);
|
||||
params = _.object( ['method', 'model', 'options'], Backbone.sync.calls.mostRecent().args );
|
||||
expect( params.method ).toEqual( 'GET' );
|
||||
expect( params.model ).toEqual( dummyModel );
|
||||
expect( params.options.error ).not.toEqual( testErrorCallback );
|
||||
return params;
|
||||
};
|
||||
|
||||
checkAuthAttempted = function(isExpected) {
|
||||
if (isExpected) {
|
||||
expect( $.ajax ).toHaveBeenCalled();
|
||||
expect( $.ajax.calls.mostRecent().args[0].url ).toEqual( authUrl );
|
||||
} else {
|
||||
expect( $.ajax ).not.toHaveBeenCalled();
|
||||
}
|
||||
};
|
||||
|
||||
it( 'should exist', function () {
|
||||
expect( model ).toBeDefined();
|
||||
});
|
||||
|
||||
it( 'should intercept 401 errors and attempt auth', function() {
|
||||
|
||||
var callParams;
|
||||
|
||||
spyOnBackboneSync(401);
|
||||
|
||||
callSync();
|
||||
|
||||
// make sure the auth attempt was initiated
|
||||
checkAuthAttempted(true);
|
||||
|
||||
// fire the success handler for the fake ajax call, with id token response data
|
||||
fakeAjaxDeferred.resolve( {id_token: 'test-id-token'} );
|
||||
|
||||
// make sure the original request was retried with token, and without custom error handling
|
||||
expect( Backbone.sync.calls.count() ).toEqual(2);
|
||||
callParams = _.object( ['method', 'model', 'options'], Backbone.sync.calls.mostRecent().args );
|
||||
expect( callParams.method ).toEqual( 'GET' );
|
||||
expect( callParams.model ).toEqual( dummyModel );
|
||||
expect( callParams.options.error ).toEqual( testErrorCallback );
|
||||
expect( callParams.options.headers.Authorization ).toEqual( 'JWT test-id-token' );
|
||||
|
||||
});
|
||||
|
||||
it( 'should not intercept non-401 errors', function() {
|
||||
|
||||
spyOnBackboneSync(403);
|
||||
|
||||
// invoke AutoAuthModel.sync
|
||||
callSync();
|
||||
|
||||
// make sure NO auth attempt was initiated
|
||||
checkAuthAttempted(false);
|
||||
|
||||
// make sure the original request was not retried
|
||||
expect( Backbone.sync.calls.count() ).toEqual(1);
|
||||
|
||||
// make sure the default error handling was invoked
|
||||
expect( testErrorCallback ).toHaveBeenCalled();
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
||||
217
cms/static/js/spec/views/programs/program_creator_spec.js
Normal file
217
cms/static/js/spec/views/programs/program_creator_spec.js
Normal file
@@ -0,0 +1,217 @@
|
||||
define([
|
||||
'backbone',
|
||||
'jquery',
|
||||
'js/programs/views/program_creator_view'
|
||||
],
|
||||
function( Backbone, $, ProgramCreatorView ) {
|
||||
'use strict';
|
||||
|
||||
describe('ProgramCreatorView', function () {
|
||||
var view = {},
|
||||
Router = Backbone.Router.extend({
|
||||
initialize: function( options ) {
|
||||
this.homeUrl = options.homeUrl;
|
||||
},
|
||||
goHome: function() {
|
||||
window.location.href = this.homeUrl;
|
||||
}
|
||||
}),
|
||||
organizations = {
|
||||
count: 1,
|
||||
previous: null,
|
||||
'num_pages': 1,
|
||||
results:[{
|
||||
'display_name': 'test-org-display_name',
|
||||
'key': 'test-org-key'
|
||||
}],
|
||||
next: null
|
||||
},
|
||||
sampleInput = {
|
||||
organizations: 'test-org-key',
|
||||
name: 'Test Course Name',
|
||||
subtitle: 'Test Course Subtitle',
|
||||
marketing_slug: 'test-management'
|
||||
},
|
||||
completeForm = function( data ) {
|
||||
view.$el.find('#program-name').val( data.name );
|
||||
view.$el.find('#program-subtitle').val( data.subtitle );
|
||||
view.$el.find('#program-org').val( data.organizations );
|
||||
|
||||
if ( data.category ) {
|
||||
view.$el.find('#program-type').val( data.category );
|
||||
}
|
||||
|
||||
if ( data.marketing_slug ) {
|
||||
view.$el.find('#program-marketing-slug').val( data.marketing_slug );
|
||||
}
|
||||
},
|
||||
verifyValidation = function ( data, invalidAttr ) {
|
||||
var errorClass = 'has-error',
|
||||
$invalidElement = view.$el.find( '[name="' + invalidAttr + '"]' ),
|
||||
$errorMsg = $invalidElement.siblings('.field-message'),
|
||||
inputErrorMsg = '';
|
||||
|
||||
completeForm( data );
|
||||
|
||||
view.$el.find('.js-create-program').click();
|
||||
inputErrorMsg = $invalidElement.data('error');
|
||||
|
||||
expect( view.model.save ).not.toHaveBeenCalled();
|
||||
expect( $invalidElement ).toHaveClass( errorClass );
|
||||
expect( $errorMsg ).toHaveClass( errorClass );
|
||||
expect( inputErrorMsg ).toBeDefined();
|
||||
expect( $errorMsg.find('.field-message-content').html() ).toEqual( inputErrorMsg );
|
||||
};
|
||||
|
||||
beforeEach( function() {
|
||||
// Set the DOM
|
||||
setFixtures( '<div class="js-program-admin"></div>' );
|
||||
|
||||
jasmine.clock().install();
|
||||
|
||||
spyOn( ProgramCreatorView.prototype, 'saveSuccess' ).and.callThrough();
|
||||
spyOn( ProgramCreatorView.prototype, 'goToView' ).and.callThrough();
|
||||
spyOn( ProgramCreatorView.prototype, 'saveError' ).and.callThrough();
|
||||
spyOn( Router.prototype, 'goHome' );
|
||||
|
||||
view = new ProgramCreatorView({
|
||||
router: new Router({
|
||||
homeUrl: '/author/home'
|
||||
})
|
||||
});
|
||||
|
||||
view.organizations.set( organizations );
|
||||
view.render();
|
||||
});
|
||||
|
||||
afterEach( function() {
|
||||
view.destroy();
|
||||
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
it( 'should exist', function () {
|
||||
expect( view ).toBeDefined();
|
||||
});
|
||||
|
||||
it ( 'should get the form data', function() {
|
||||
var formData = {};
|
||||
|
||||
completeForm( sampleInput );
|
||||
formData = view.getData();
|
||||
|
||||
expect( formData.name ).toEqual( sampleInput.name );
|
||||
expect( formData.subtitle ).toEqual( sampleInput.subtitle );
|
||||
expect( formData.organizations[0].key ).toEqual( sampleInput.organizations );
|
||||
});
|
||||
|
||||
it( 'should submit the form when the user clicks submit', function() {
|
||||
var programId = 123;
|
||||
|
||||
completeForm( sampleInput );
|
||||
|
||||
spyOn( $, 'ajax' ).and.callFake( function( event ) {
|
||||
event.success({ id: programId });
|
||||
});
|
||||
|
||||
view.$el.find('.js-create-program').click();
|
||||
|
||||
expect( $.ajax ).toHaveBeenCalled();
|
||||
expect( view.saveSuccess ).toHaveBeenCalled();
|
||||
expect( view.goToView ).toHaveBeenCalledWith( String( programId ) );
|
||||
expect( view.saveError ).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it( 'should run the saveError when model save failures occur', function() {
|
||||
spyOn( $, 'ajax' ).and.callFake( function( event ) {
|
||||
event.error();
|
||||
});
|
||||
|
||||
// Fill out the form with valid data so that form model validation doesn't
|
||||
// prevent the model from being saved.
|
||||
completeForm( sampleInput );
|
||||
view.$el.find('.js-create-program').click();
|
||||
|
||||
expect( $.ajax ).toHaveBeenCalled();
|
||||
expect( view.saveSuccess ).not.toHaveBeenCalled();
|
||||
expect( view.saveError ).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it( 'should set the model when valid form data is submitted', function() {
|
||||
completeForm( sampleInput );
|
||||
|
||||
spyOn( $, 'ajax' ).and.callFake( function( event ) {
|
||||
event.success({ id: 10001110101 });
|
||||
});
|
||||
|
||||
view.$el.find('.js-create-program').click();
|
||||
|
||||
expect( view.model.get('name') ).toEqual( sampleInput.name );
|
||||
expect( view.model.get('subtitle') ).toEqual( sampleInput.subtitle );
|
||||
expect( view.model.get('organizations')[0].key ).toEqual( sampleInput.organizations );
|
||||
expect( view.model.get('marketing_slug') ).toEqual( sampleInput.marketing_slug );
|
||||
});
|
||||
|
||||
it( 'should not set the model when an invalid program name is submitted', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
|
||||
spyOn( view.model, 'save' );
|
||||
|
||||
// No name provided.
|
||||
invalidInput.name = '';
|
||||
verifyValidation( invalidInput, 'name' );
|
||||
|
||||
// Name is too long.
|
||||
invalidInput.name = 'x'.repeat(256);
|
||||
verifyValidation( invalidInput, 'name' );
|
||||
});
|
||||
|
||||
it( 'should not set the model when an invalid program subtitle is submitted', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
|
||||
spyOn( view.model, 'save' );
|
||||
|
||||
// Subtitle is too long.
|
||||
invalidInput.subtitle = 'x'.repeat(300);
|
||||
verifyValidation( invalidInput, 'subtitle' );
|
||||
});
|
||||
|
||||
it( 'should not set the model when an invalid category is submitted', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
|
||||
spyOn( view.model, 'save' );
|
||||
|
||||
// Category other than 'xseries' selected.
|
||||
invalidInput.category = 'yseries';
|
||||
verifyValidation( invalidInput, 'category' );
|
||||
});
|
||||
|
||||
it( 'should not set the model when an invalid organization key is submitted', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
|
||||
spyOn( view.model, 'save' );
|
||||
|
||||
// No organization selected.
|
||||
invalidInput.organizations = 'false';
|
||||
verifyValidation( invalidInput, 'organizations' );
|
||||
});
|
||||
|
||||
it( 'should not set the model when an invalid marketing slug is submitted', function() {
|
||||
var invalidInput = $.extend({}, sampleInput);
|
||||
|
||||
spyOn( view.model, 'save' );
|
||||
|
||||
// Marketing slug is too long.
|
||||
invalidInput.marketing_slug = 'x'.repeat(256);
|
||||
verifyValidation( invalidInput, 'marketing_slug' );
|
||||
});
|
||||
|
||||
it( 'should abort the view when the cancel button is clicked', function() {
|
||||
completeForm( sampleInput );
|
||||
expect( view.$parentEl.html().length ).toBeGreaterThan( 0 );
|
||||
view.$el.find('.js-abort-view').click();
|
||||
expect( view.router.goHome ).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
533
cms/static/js/spec/views/programs/program_details_spec.js
Normal file
533
cms/static/js/spec/views/programs/program_details_spec.js
Normal file
@@ -0,0 +1,533 @@
|
||||
define([
|
||||
'jquery',
|
||||
'js/programs/collections/course_runs_collection',
|
||||
'js/programs/models/program_model',
|
||||
'js/programs/views/course_run_view',
|
||||
'js/programs/views/program_details_view',
|
||||
'js/programs/utils/constants'
|
||||
],
|
||||
function( $, CourseRunsCollection, ProgramModel, CourseRunView,
|
||||
ProgramDetailsView, constants ) {
|
||||
'use strict';
|
||||
|
||||
/* jshint maxlen: 300 */
|
||||
describe('ProgramDetailsView', function () {
|
||||
var view = {},
|
||||
model = {},
|
||||
courseRunsList = [
|
||||
{
|
||||
id: 'course-v1:edX+DemoX+Demo_Course',
|
||||
name: 'edX Demonstration Course',
|
||||
number: 'DemoX',
|
||||
org: 'edX',
|
||||
short_description: null,
|
||||
effort: null,
|
||||
media: {
|
||||
course_image: {
|
||||
uri: '/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg'
|
||||
},
|
||||
course_video: {
|
||||
uri: null
|
||||
}
|
||||
},
|
||||
start: 'May 23, 2015',
|
||||
start_type: 'timestamp',
|
||||
start_display: 'Feb. 5, 2013',
|
||||
end: null,
|
||||
enrollment_start: null,
|
||||
enrollment_end: null,
|
||||
blocks_url: 'http://127.0.0.1:8000/api/courses/v1/blocks/?course_id=course-v1%3AedX%2BDemoX%2BDemo_Course'
|
||||
},
|
||||
{
|
||||
id: 'course-v1:edx+Krampus25+2015_12_05',
|
||||
name: 'Krampusnacht',
|
||||
number: 'Krampus25',
|
||||
org: 'edx',
|
||||
short_description: null,
|
||||
effort: null,
|
||||
media: {
|
||||
course_image: {
|
||||
uri: '/asset-v1:edx+Krampus25+2015_12_05+type@asset+block@images_course_image.jpg'
|
||||
},
|
||||
course_video: {
|
||||
uri: null
|
||||
}
|
||||
},
|
||||
start: '2030-01-01T00:00:00Z',
|
||||
start_type: 'empty',
|
||||
start_display: null,
|
||||
end: null,
|
||||
enrollment_start: null,
|
||||
enrollment_end: null,
|
||||
blocks_url: 'http://127.0.0.1:8000/api/courses/v1/blocks/?course_id=course-v1%3Aedx%2BKrampus25%2B2015_12_05'
|
||||
},
|
||||
{
|
||||
id: 'course-v1:edx+shiaLB101+2016_01',
|
||||
name: 'Shia "The Beef"',
|
||||
number: 'shiaLB101',
|
||||
org: 'edx',
|
||||
short_description: null,
|
||||
effort: null,
|
||||
media: {
|
||||
course_image: {
|
||||
uri: '/asset-v1:edx+shiaLB101+2016_01+type@asset+block@images_course_image.jpg'
|
||||
},
|
||||
course_video: {
|
||||
uri: null
|
||||
}
|
||||
},
|
||||
start: '2030-01-01T00:00:00Z',
|
||||
start_type: 'empty',
|
||||
start_display: null,
|
||||
end: null,
|
||||
enrollment_start: null,
|
||||
enrollment_end: null,
|
||||
blocks_url: 'http://127.0.0.1:8000/api/courses/v1/blocks/?course_id=course-v1%3Aedx%2BshiaLB101%2B2016_01'
|
||||
}
|
||||
],
|
||||
programData = {
|
||||
category: 'xseries',
|
||||
course_codes: [{
|
||||
display_name: 'test-course-display_name',
|
||||
key: 'test-course-key',
|
||||
organization: {
|
||||
display_name: 'test-org-display_name',
|
||||
key: 'test-org-key'
|
||||
},
|
||||
run_modes: [
|
||||
{
|
||||
course_key: 'course-v1:edX+DemoX+Demo_Course',
|
||||
mode_slug: 'honor',
|
||||
sku: null,
|
||||
start: 'May 23, 2015'
|
||||
}, {
|
||||
course_key: 'course-v1:edX+DemoX+Demo_Course',
|
||||
mode_slug: 'honor',
|
||||
sku: null,
|
||||
start: 'August 01, 2015'
|
||||
}, {
|
||||
course_key: 'course-v1:edX+DemoX+Demo_Course',
|
||||
mode_slug: 'honor',
|
||||
sku: null,
|
||||
start: 'December 11, 2015'
|
||||
}
|
||||
]
|
||||
}],
|
||||
created: '2015-10-20T18:11:46.854451Z',
|
||||
id: 5,
|
||||
marketing_slug: 'test-program-slug',
|
||||
modified: '2015-10-20T18:11:46.854735Z',
|
||||
name: 'test-program-5',
|
||||
organizations: [{
|
||||
display_name: 'test-org-display_name',
|
||||
key: 'test-org-key'
|
||||
}],
|
||||
status: 'unpublished',
|
||||
subtitle: 'test-subtitle'
|
||||
},
|
||||
testTimeoutInterval = 100,
|
||||
errorClass = 'has-error',
|
||||
addCourse,
|
||||
completeCourseForm,
|
||||
dropdownSelect,
|
||||
editField,
|
||||
keyPress,
|
||||
openPublishModal,
|
||||
resetCourseCodes,
|
||||
testHidingButtonsAfterPublish,
|
||||
testInvalidUpdate,
|
||||
testUnchangedFieldBlur,
|
||||
testUpdatedFieldBlur;
|
||||
|
||||
addCourse = function() {
|
||||
var $addCourseBtn = view.$el.find('.js-add-course').first(),
|
||||
$form,
|
||||
$submitBtn;
|
||||
|
||||
expect( view.$el.find('.course-details').length ).toEqual( 1 );
|
||||
$addCourseBtn.click();
|
||||
$form = view.$('.js-course-form');
|
||||
$submitBtn = $form.find('.js-select-course');
|
||||
completeCourseForm();
|
||||
$submitBtn.click();
|
||||
expect( $form.find('.field-message.has-error').length ).toEqual( 0 );
|
||||
};
|
||||
|
||||
completeCourseForm = function() {
|
||||
var $form = view.$('.js-course-form');
|
||||
|
||||
$form.find('.course-key').val('123');
|
||||
$form.find('.display-name').val('test course 1');
|
||||
};
|
||||
|
||||
dropdownSelect = function( $select, value ) {
|
||||
$select.find('option:selected').prop('selected', false);
|
||||
$select.val(value).prop('selected', true);
|
||||
$select.trigger('change');
|
||||
};
|
||||
|
||||
editField = function( el, str ) {
|
||||
var $input = view.$el.find( el ),
|
||||
$btn = $input.next( '.js-enable-edit' );
|
||||
|
||||
expect( document.activeElement ).not.toEqual( $input[0] );
|
||||
expect( $input ).not.toHaveClass( 'edit' );
|
||||
expect( $input ).toHaveClass( 'is-hidden' );
|
||||
|
||||
$btn.click();
|
||||
|
||||
$input.val( str );
|
||||
|
||||
// Enable editing
|
||||
expect( $input ).not.toHaveClass( 'is-hidden' );
|
||||
expect( $input ).toHaveClass( 'edit' );
|
||||
};
|
||||
|
||||
keyPress = function( $el, key ) {
|
||||
$el.trigger({
|
||||
type: 'keydown',
|
||||
keyCode: key,
|
||||
which: key,
|
||||
charCode: key
|
||||
});
|
||||
};
|
||||
|
||||
openPublishModal = function() {
|
||||
var $publishBtn = view.$el.find('.js-publish-program'),
|
||||
defaultStatus = programData.status,
|
||||
publishedStatus = 'active';
|
||||
|
||||
expect( view.modalView ).not.toBeDefined();
|
||||
expect( view.model.get( 'status' ) ).toEqual( defaultStatus );
|
||||
expect( view.model.get( 'status' ) ).not.toEqual( publishedStatus );
|
||||
|
||||
$publishBtn.click();
|
||||
};
|
||||
|
||||
resetCourseCodes = function() {
|
||||
var originalRun = programData.course_codes[0];
|
||||
|
||||
programData.course_codes = [originalRun];
|
||||
};
|
||||
|
||||
testUnchangedFieldBlur = function( el ) {
|
||||
var $input = view.$el.find( el ),
|
||||
$btn = view.$el.find( '.js-add-course' ),
|
||||
title = $input.val(),
|
||||
update = title;
|
||||
|
||||
editField( el, update );
|
||||
$btn.focus();
|
||||
$input.blur();
|
||||
|
||||
expect( title ).toEqual( update );
|
||||
expect( view.model.save ).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
testUpdatedFieldBlur = function( el, update ) {
|
||||
var $input = view.$el.find( el ),
|
||||
$btn = view.$el.find( '.js-add-course' );
|
||||
|
||||
expect( $input.val() ).not.toEqual( update );
|
||||
|
||||
editField( el, update );
|
||||
|
||||
$btn.focus();
|
||||
$input.blur();
|
||||
|
||||
expect( $input.val() ).toEqual( update );
|
||||
expect( view.model.save ).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
testHidingButtonsAfterPublish = function( el ) {
|
||||
expect( view.$el.find( el ).length ).toBeGreaterThan( 0 );
|
||||
view.model.set({ status: 'active' });
|
||||
view.render();
|
||||
expect( view.$el.find( el ).length ).toEqual( 0 );
|
||||
};
|
||||
|
||||
testInvalidUpdate = function( el, update ) {
|
||||
var $input = view.$el.find( el ),
|
||||
$btn = view.$el.find( '.js-add-course' );
|
||||
|
||||
editField( el, update );
|
||||
|
||||
$btn.focus();
|
||||
$input.blur();
|
||||
|
||||
expect( $input ).toHaveClass( errorClass );
|
||||
expect( view.model.save ).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
beforeEach( function() {
|
||||
// Set the DOM
|
||||
setFixtures( '<div class="js-program-admin"></div>' );
|
||||
|
||||
jasmine.clock().install();
|
||||
|
||||
spyOn( ProgramModel.prototype, 'set' ).and.callThrough();
|
||||
spyOn( ProgramModel.prototype, 'save' );
|
||||
spyOn( CourseRunsCollection.prototype, 'fetch' );
|
||||
|
||||
model = new ProgramModel();
|
||||
model.set( programData );
|
||||
|
||||
view = new ProgramDetailsView({
|
||||
model: model
|
||||
});
|
||||
|
||||
view.courseRuns.set( courseRunsList );
|
||||
view.courseRuns.parse({ results: courseRunsList });
|
||||
});
|
||||
|
||||
afterEach( function() {
|
||||
resetCourseCodes();
|
||||
view.undelegateEvents();
|
||||
view.remove();
|
||||
|
||||
jasmine.clock().uninstall();
|
||||
});
|
||||
|
||||
describe( 'View data', function() {
|
||||
it( 'should exist', function () {
|
||||
expect( view ).toBeDefined();
|
||||
});
|
||||
|
||||
it( 'should render all of the run_modes from the model', function() {
|
||||
var $runs = view.$el.find('.js-course-runs'),
|
||||
domLength = $runs.find('.js-remove-run').length,
|
||||
objLength = programData.course_codes[0].run_modes.length;
|
||||
|
||||
expect( domLength ).toEqual( objLength );
|
||||
});
|
||||
});
|
||||
|
||||
describe( 'Delete data', function() {
|
||||
it( 'should remove a course when the delete button is clicked', function() {
|
||||
var $el = view.$el.find('.js-course-list'),
|
||||
$removeRunBtn = $el.find('.js-remove-course').first(),
|
||||
count = programData.course_codes.length;
|
||||
|
||||
expect( $el.find('.js-remove-course').length ).toEqual( count );
|
||||
$removeRunBtn.click();
|
||||
|
||||
setTimeout( function() {
|
||||
expect( $el.find('.js-remove-course').length ).toEqual( count - 1 );
|
||||
}, testTimeoutInterval );
|
||||
|
||||
jasmine.clock().tick( testTimeoutInterval + 1 );
|
||||
});
|
||||
|
||||
it( 'should remove a course run when the delete button is clicked', function() {
|
||||
var $runs = view.$el.find('.js-course-runs'),
|
||||
$removeRunBtn = $runs.find('.js-remove-run').first(),
|
||||
count = programData.course_codes[0].run_modes.length;
|
||||
|
||||
expect( $runs.find('.js-remove-run').length ).toEqual( count );
|
||||
$removeRunBtn.click();
|
||||
|
||||
setTimeout( function() {
|
||||
expect( $runs.find('.js-remove-run').length ).toEqual( count - 1 );
|
||||
}, testTimeoutInterval );
|
||||
|
||||
jasmine.clock().tick( testTimeoutInterval + 1 );
|
||||
});
|
||||
|
||||
it( 'should not show the delete course button if program status is not unpublished', function() {
|
||||
testHidingButtonsAfterPublish('.js-remove-course');
|
||||
});
|
||||
|
||||
it( 'should not show the add course button if program status is not unpublished', function() {
|
||||
testHidingButtonsAfterPublish('.js-add-course');
|
||||
});
|
||||
|
||||
it( 'should not show the delete run button if program status is not unpublished', function() {
|
||||
testHidingButtonsAfterPublish('.js-remove-run');
|
||||
});
|
||||
});
|
||||
|
||||
describe( 'Add data', function() {
|
||||
it( 'should add a new course details view on click of the add course button', function() {
|
||||
var $btn = view.$el.find('.js-add-course').first();
|
||||
|
||||
expect( view.$('.js-course-form').length ).toEqual( 0 );
|
||||
$btn.click();
|
||||
expect( view.$('.js-course-form').length ).toEqual( 1 );
|
||||
});
|
||||
|
||||
it( 'should add a course when the form is submitted', function() {
|
||||
addCourse();
|
||||
});
|
||||
|
||||
it( 'should not submit the course form when it is incomplete', function() {
|
||||
var $addCourseBtn = view.$el.find('.js-add-course').first(),
|
||||
$form,
|
||||
$submitBtn;
|
||||
|
||||
expect( view.$el.find('.course-details').length ).toEqual( 1 );
|
||||
$addCourseBtn.click();
|
||||
$form = view.$('.js-course-form');
|
||||
expect( $form.find('.field-message.has-error').length ).toEqual( 0 );
|
||||
$submitBtn = $form.find('.js-select-course');
|
||||
$submitBtn.click();
|
||||
expect( $form.find('.field-message.has-error').length ).toEqual( 2 );
|
||||
});
|
||||
|
||||
it( 'should allow a user to add a course run', function() {
|
||||
var runSelect = '.js-course-run-select',
|
||||
$runSelect,
|
||||
savedRunCount = programData.course_codes[0].run_modes.length;
|
||||
|
||||
addCourse();
|
||||
expect( view.$(runSelect).length ).toEqual(0);
|
||||
view.$('.js-add-course-run').first().click();
|
||||
|
||||
$runSelect = view.$(runSelect);
|
||||
expect( $runSelect.length ).toEqual(1);
|
||||
expect( view.$('.js-remove-run').length ).toEqual(savedRunCount);
|
||||
dropdownSelect($runSelect, courseRunsList[0].id);
|
||||
expect( view.$(runSelect).length ).toEqual(0);
|
||||
expect( view.$('.js-remove-run').length ).toEqual(savedRunCount + 1);
|
||||
});
|
||||
|
||||
it( 'should update the course run dropdown if multiple are open and one is selected', function() {
|
||||
var runSelect = '.js-course-run-select',
|
||||
$addRunBtn,
|
||||
$courseView,
|
||||
courseRunOptionsCount = courseRunsList.length + 1;
|
||||
|
||||
addCourse();
|
||||
expect( view.$(runSelect).length ).toEqual(0);
|
||||
|
||||
$courseView = view.$('.course-container').last();
|
||||
$addRunBtn = $courseView.find('.js-add-course-run');
|
||||
$addRunBtn.click();
|
||||
|
||||
expect( view.$(runSelect).length ).toEqual(1);
|
||||
expect( view.$(runSelect).find('option').length ).toEqual(courseRunOptionsCount);
|
||||
|
||||
$addRunBtn.click();
|
||||
expect( view.$(runSelect).length ).toEqual(2);
|
||||
|
||||
dropdownSelect(view.$(runSelect).first(), courseRunsList[0].id);
|
||||
expect( view.$(runSelect).length ).toEqual(1);
|
||||
expect( view.$(runSelect).find('option').length ).toEqual(courseRunOptionsCount - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe( 'Edit data', function() {
|
||||
it( 'should enable a user to edit the name, subtitle and marketing slug fields', function() {
|
||||
editField( '.program-name', 'name' );
|
||||
editField( '.program-subtitle', 'subtitle' );
|
||||
editField( '.program-marketing-slug', 'marketing-slug' );
|
||||
});
|
||||
|
||||
it( 'should not send an API call if a user does not change the value of an editable field', function() {
|
||||
testUnchangedFieldBlur( '.program-name' );
|
||||
testUnchangedFieldBlur( '.program-subtitle' );
|
||||
testUnchangedFieldBlur( '.program-marketing-slug' );
|
||||
});
|
||||
|
||||
it( 'should send an API call if a user changes the value of an editable field', function() {
|
||||
testUpdatedFieldBlur( '.program-name', 'new-title' );
|
||||
testUpdatedFieldBlur( '.program-subtitle', 'new-subtitle' );
|
||||
testUpdatedFieldBlur( '.program-marketing-slug', 'new-marketing-slug' );
|
||||
});
|
||||
|
||||
it( 'should show error messaging if the updated required field is empty', function() {
|
||||
testInvalidUpdate( '.program-name', '' );
|
||||
});
|
||||
|
||||
it( 'should show error messaging if the updated field value is too long', function() {
|
||||
var chars256 = 'x'.repeat(256);
|
||||
|
||||
testInvalidUpdate( '.program-name', chars256 );
|
||||
testInvalidUpdate( '.program-subtitle', chars256 );
|
||||
testInvalidUpdate( '.program-marketing-slug', chars256 );
|
||||
});
|
||||
|
||||
it( 'should create a POST config object by default', function() {
|
||||
var config = view.model.getConfig();
|
||||
|
||||
expect( config.type ).toEqual( 'POST' );
|
||||
expect( config.contentType ).toEqual( 'application/json' );
|
||||
expect( config.data ).not.toBeDefined();
|
||||
});
|
||||
|
||||
it( 'should create a PATCH config object when passed in object sets patch as true', function() {
|
||||
var data = { name: 'patched name' },
|
||||
config = view.model.getConfig({
|
||||
patch: true,
|
||||
update: data
|
||||
});
|
||||
|
||||
expect( config.type ).toEqual( 'PATCH' );
|
||||
expect( config.contentType ).toEqual( 'application/merge-patch+json' );
|
||||
expect( config.data ).toBeDefined();
|
||||
expect( config.data ).toEqual( JSON.stringify( data ) );
|
||||
});
|
||||
});
|
||||
|
||||
describe( 'Publish a Program', function() {
|
||||
it( 'should open the publish modal when the publish button is clicked', function() {
|
||||
openPublishModal();
|
||||
expect( view.modalView ).toBeDefined();
|
||||
});
|
||||
|
||||
it( 'should publish a program when the publish confirm button is clicked', function() {
|
||||
var defaultStatus = programData.status,
|
||||
publishedStatus = 'active';
|
||||
|
||||
openPublishModal();
|
||||
expect( view.modalView ).toBeDefined();
|
||||
|
||||
view.$el.find('.js-confirm').click();
|
||||
|
||||
// Model should be set and save called
|
||||
expect( view.model.set ).toHaveBeenCalled();
|
||||
expect( view.model.get( 'status' ) ).not.toEqual( defaultStatus );
|
||||
expect( view.model.get( 'status' ) ).toEqual( publishedStatus );
|
||||
expect( view.model.save ).toHaveBeenCalled();
|
||||
|
||||
// Publish button should be removed once API has completed its call
|
||||
expect( view.$el.find('.js-publish-program').length ).toEqual( 1 );
|
||||
view.model.trigger( 'sync' );
|
||||
expect( view.$el.find('.js-publish-program').length ).toEqual( 0 );
|
||||
});
|
||||
|
||||
it( 'should show a validation error when publish button pressed if validation fails', function() {
|
||||
var $input = view.$el.find( '#program-marketing-slug' );
|
||||
|
||||
view.model.set('marketing_slug', '');
|
||||
openPublishModal();
|
||||
expect( view.modalView ).not.toBeDefined();
|
||||
expect( $input ).toHaveClass( errorClass );
|
||||
});
|
||||
|
||||
it( 'should destroy the publish modal when the cancel button is clicked', function() {
|
||||
openPublishModal();
|
||||
expect( view.modalView ).toBeDefined();
|
||||
|
||||
// Close the modal
|
||||
view.$el.find('.js-cancel').click();
|
||||
|
||||
// Expect the modal DOM elements to not be there anymore
|
||||
expect( view.$el.find('.js-cancel').length ).toEqual( 0 );
|
||||
expect( view.modalView.$parentEl.html().length ).toEqual( 0 );
|
||||
});
|
||||
|
||||
it( 'should destroy the publish modal when the esc key is pressed', function() {
|
||||
openPublishModal();
|
||||
expect( view.modalView ).toBeDefined();
|
||||
|
||||
// Close the modal
|
||||
keyPress( view.modalView.$el, constants.keyCodes.esc );
|
||||
|
||||
// Expect the modal DOM elements to not be there anymore
|
||||
expect( view.$el.find('.js-cancel').length ).toEqual( 0 );
|
||||
expect( view.modalView.$parentEl.html().length ).toEqual( 0 );
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
15
cms/static/sass/_base-v2.scss
Normal file
15
cms/static/sass/_base-v2.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
// studio - base styling
|
||||
// ====================
|
||||
html {
|
||||
height: 102%; // force scrollbar to prevent jump when scroll appears, cannot use overflow because it breaks drag
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: $fg-min-width;
|
||||
background: $gray-l5;
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
footer.primary{
|
||||
font-size: font-size(x-small);
|
||||
}
|
||||
@@ -590,35 +590,6 @@ hr.divide {
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@include transition(opacity $tmg-f3 ease-out 0s);
|
||||
@include font-size(12);
|
||||
@extend %t-regular;
|
||||
@extend %ui-depth5;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0 10px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
line-height: 26px;
|
||||
color: $white;
|
||||
pointer-events: none;
|
||||
opacity: 0.0;
|
||||
|
||||
&:after {
|
||||
@include font-size(20);
|
||||
content: '▾';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: -14px;
|
||||
left: 50%;
|
||||
margin-left: -7px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// +Utility - Basic
|
||||
// ====================
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
@import 'elements/modal-window';
|
||||
@import 'elements/uploaded-assets'; // layout for asset tables
|
||||
@import 'elements/creative-commons';
|
||||
@import 'elements/tooltip';
|
||||
|
||||
// +Base - Specific Views
|
||||
// ====================
|
||||
|
||||
@@ -7,3 +7,13 @@
|
||||
@import 'config';
|
||||
|
||||
// Extensions
|
||||
@import 'partials/variables';
|
||||
@import 'mixins-v2';
|
||||
@import 'base-v2';
|
||||
@import 'elements-v2/controls';
|
||||
@import 'elements-v2/header';
|
||||
@import 'elements-v2/navigation';
|
||||
@import 'elements/footer';
|
||||
@import 'elements-v2/sock';
|
||||
@import 'elements-v2/tooltip';
|
||||
@import 'programs/build';
|
||||
|
||||
4
cms/static/sass/_mixins-v2.scss
Normal file
4
cms/static/sass/_mixins-v2.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
// pill button
|
||||
%ui-btn-pill {
|
||||
border-radius: ($baseline/5);
|
||||
}
|
||||
45
cms/static/sass/elements-v2/_controls.scss
Normal file
45
cms/static/sass/elements-v2/_controls.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
// +UI Dropdown Button - Extend
|
||||
// ====================
|
||||
%ui-btn-dd {
|
||||
@extend %ui-btn;
|
||||
@extend %ui-btn-pill;
|
||||
padding:($baseline/4) ($baseline/2);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
text-align: center;
|
||||
|
||||
&:hover, &:active {
|
||||
@extend %ui-fake-link;
|
||||
border-color: $gray-l3;
|
||||
}
|
||||
|
||||
&.current, &.active, &.is-selected {
|
||||
box-shadow: inset 0 1px 2px 1px $shadow-l1;
|
||||
border-color: $gray-l3;
|
||||
}
|
||||
}
|
||||
|
||||
// +UI Nav Dropdown Button - Extend
|
||||
// ====================
|
||||
%ui-btn-dd-nav-primary {
|
||||
@extend %ui-btn-dd;
|
||||
background: $white;
|
||||
border-color: $white;
|
||||
color: $gray-d1;
|
||||
|
||||
&:hover, &:active {
|
||||
background: $white;
|
||||
color: $blue-s1;
|
||||
}
|
||||
|
||||
&.current, &.active {
|
||||
background: $white;
|
||||
color: $gray-d4;
|
||||
|
||||
&:hover, &:active {
|
||||
color: $blue-s1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
172
cms/static/sass/elements-v2/_header.scss
Normal file
172
cms/static/sass/elements-v2/_header.scss
Normal file
@@ -0,0 +1,172 @@
|
||||
// studio - elements - global header
|
||||
// ====================
|
||||
|
||||
.wrapper-header {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-shadow: 0 1px 2px 0 $shadow-l1;
|
||||
margin: 0;
|
||||
padding: 0 $baseline;
|
||||
background: $white;
|
||||
|
||||
header.primary {
|
||||
@include clearfix();
|
||||
@include span(12);
|
||||
@include float(none);
|
||||
box-sizing: border-box;
|
||||
max-width: $fg-max-width;
|
||||
min-width: $fg-min-width;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// basic layout
|
||||
.wrapper-l, .wrapper-r {
|
||||
background: $white;
|
||||
}
|
||||
|
||||
.wrapper-l {
|
||||
@include span(7);
|
||||
}
|
||||
|
||||
.wrapper-r {
|
||||
@include span(4 last);
|
||||
@include text-align(right);
|
||||
}
|
||||
|
||||
.branding, .info-course, .nav-course, .nav-account, .nav-pitch {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.user-language-selector {
|
||||
width: 120px;
|
||||
display: inline-block;
|
||||
margin: 0 10px 0 5px;
|
||||
vertical-align: sub;
|
||||
|
||||
.language-selector {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-account {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
// basic layout - nav items
|
||||
.nav-dd {
|
||||
.nav-item {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.title{
|
||||
@extend %ui-btn-dd-nav-primary;
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
line-height: 16px;
|
||||
margin-top: 6px;
|
||||
font-size: font-size(base);
|
||||
font-weight: font-weight(semi-bold);
|
||||
.nav-sub .nav-item {
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item a {
|
||||
color: $gray-d1;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: $blue-s1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// specific elements - branding
|
||||
.branding {
|
||||
padding: ($baseline*0.75) 0;
|
||||
|
||||
.brand-link {
|
||||
display: block;
|
||||
|
||||
.brand-image {
|
||||
max-height: ($baseline*2);
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// specific elements - account-based nav
|
||||
.nav-account {
|
||||
position: relative;
|
||||
padding: ($baseline*0.75) 0;
|
||||
|
||||
.nav-sub {
|
||||
@include text-align(left);
|
||||
}
|
||||
|
||||
.nav-account-help {
|
||||
|
||||
.wrapper-nav-sub {
|
||||
width: ($baseline*10);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-account-user {
|
||||
|
||||
.title {
|
||||
max-width: ($baseline*6.5);
|
||||
display: inline-block;
|
||||
max-width: 84%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-language-form {
|
||||
margin-top: 4px;
|
||||
.language-selector {
|
||||
width: 130px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ====================
|
||||
|
||||
// CASE: user signed in
|
||||
.is-signedin {
|
||||
|
||||
.wrapper-l {
|
||||
width: flex-grid(8,12);
|
||||
}
|
||||
|
||||
.wrapper-r {
|
||||
width: flex-grid(4,12);
|
||||
}
|
||||
|
||||
.branding {
|
||||
@include margin-right(2%);
|
||||
}
|
||||
|
||||
.nav-account {
|
||||
top: ($baseline/4);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
cms/static/sass/elements-v2/_navigation.scss
Normal file
22
cms/static/sass/elements-v2/_navigation.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
// skip navigation
|
||||
.nav-skip,
|
||||
.transcript-skip {
|
||||
@include left(0);
|
||||
font-size: font-size(small);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: -($baseline*30);
|
||||
overflow: hidden;
|
||||
background: $white;
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
padding: ($baseline*0.75) ($baseline/2);
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
position: relative;
|
||||
top: auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
135
cms/static/sass/elements-v2/_sock.scss
Normal file
135
cms/static/sass/elements-v2/_sock.scss
Normal file
@@ -0,0 +1,135 @@
|
||||
// studio - elements - support sock
|
||||
// ====================
|
||||
|
||||
.wrapper-sock {
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
margin: ($baseline*2) 0 0 0;
|
||||
border-top: 1px solid $gray-l4;
|
||||
width: 100%;
|
||||
|
||||
.wrapper-inner {
|
||||
@include linear-gradient($gray-d3 0%, $gray-d3 98%, $black 100%);
|
||||
display: none;
|
||||
width: 100% !important;
|
||||
border-bottom: 1px solid $white;
|
||||
padding: 0 $baseline !important;
|
||||
}
|
||||
|
||||
// sock - actions
|
||||
.list-cta {
|
||||
@extend %ui-depth1;
|
||||
position: absolute;
|
||||
top: -($baseline*0.75);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
|
||||
.cta-show-sock {
|
||||
@extend %ui-btn-pill;
|
||||
background: $gray-l5;
|
||||
font-size: font-size(x-small);
|
||||
padding: ($baseline/2) $baseline;
|
||||
color: $gray;
|
||||
|
||||
.icon {
|
||||
@include margin-right($baseline/4);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $blue;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sock - additional help
|
||||
.sock {
|
||||
@include clearfix();
|
||||
@include span(12);
|
||||
max-width: $fg-max-width;
|
||||
min-width: $fg-min-width;
|
||||
margin: 0 auto;
|
||||
padding: ($baseline*2) 0;
|
||||
color: $gray-l3;
|
||||
|
||||
// shared elements
|
||||
.support,
|
||||
.feedback {
|
||||
box-sizing: border-box;
|
||||
|
||||
.title {
|
||||
color: $white;
|
||||
margin-bottom: ($baseline/2);
|
||||
}
|
||||
|
||||
.copy {
|
||||
margin: 0 0 $baseline 0;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
list-style: none;
|
||||
|
||||
.action-item {
|
||||
@include float(left);
|
||||
@include margin-right($baseline/2);
|
||||
margin-bottom: ($baseline/2);
|
||||
|
||||
&:last-child {
|
||||
@include margin-right(0);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: block;
|
||||
|
||||
.icon {
|
||||
vertical-align: middle;
|
||||
@include margin-right($baseline/4);
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
@extend .sr-only;
|
||||
}
|
||||
}
|
||||
|
||||
.action-primary {
|
||||
@extend %btn-brand;
|
||||
@extend %btn-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// studio support content
|
||||
.support {
|
||||
@include float(left);
|
||||
@include span(8);
|
||||
margin-right: flex-gutter();
|
||||
|
||||
.action-item {
|
||||
width: flexgrid(4,8);
|
||||
}
|
||||
}
|
||||
|
||||
// studio feedback content
|
||||
.feedback {
|
||||
@include float(left);
|
||||
@include span(4);
|
||||
.action-item {
|
||||
width: flexgrid(4,4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// case: sock content is shown
|
||||
&.is-shown {
|
||||
border-color: $gray-d3;
|
||||
|
||||
.list-cta .cta-show-sock {
|
||||
background: $gray-d3;
|
||||
border-color: $gray-d3;
|
||||
color: $white;
|
||||
font-size: font-size(small);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
cms/static/sass/elements-v2/_tooltip.scss
Normal file
25
cms/static/sass/elements-v2/_tooltip.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
.tooltip {
|
||||
@include transition(opacity $tmg-f3 ease-out 0s);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0 10px;
|
||||
border-radius: 3px;
|
||||
background: $black-t4;
|
||||
line-height: 26px;
|
||||
font-size: font-size(x-small);
|
||||
color: $white;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
|
||||
&:after {
|
||||
font-size: font-size(x-large);
|
||||
content: '▾';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: -14px;
|
||||
left: 50%;
|
||||
margin-left: -7px;
|
||||
color: $black-t4;
|
||||
}
|
||||
}
|
||||
27
cms/static/sass/elements/_tooltip.scss
Normal file
27
cms/static/sass/elements/_tooltip.scss
Normal file
@@ -0,0 +1,27 @@
|
||||
.tooltip {
|
||||
@include transition(opacity $tmg-f3 ease-out 0s);
|
||||
@include font-size(12);
|
||||
@extend %t-regular;
|
||||
@extend %ui-depth5;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0 10px;
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
line-height: 26px;
|
||||
color: $white;
|
||||
pointer-events: none;
|
||||
opacity: 0.0;
|
||||
|
||||
&:after {
|
||||
@include font-size(20);
|
||||
content: '▾';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: -14px;
|
||||
left: 50%;
|
||||
margin-left: -7px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ $black-t0: rgba($black, 0.125);
|
||||
$black-t1: rgba($black, 0.25);
|
||||
$black-t2: rgba($black, 0.5);
|
||||
$black-t3: rgba($black, 0.75);
|
||||
$black-t4: rgba($black, 0.85);
|
||||
|
||||
$white: rgb(255,255,255);
|
||||
$white-t0: rgba($white, 0.125);
|
||||
|
||||
10
cms/static/sass/programs/_app-container.scss
Normal file
10
cms/static/sass/programs/_app-container.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
// ------------------------------
|
||||
// Programs: App Container
|
||||
|
||||
// About: styling for setting up the wrapper.
|
||||
.program-app {
|
||||
&.layout-1q3q {
|
||||
max-width: 1250px;
|
||||
}
|
||||
}
|
||||
9
cms/static/sass/programs/_build.scss
Normal file
9
cms/static/sass/programs/_build.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
// ------------------------------
|
||||
// Programs: Main Style Compile
|
||||
|
||||
// About: Sass compile for the Programs IDA.
|
||||
|
||||
@import 'components';
|
||||
@import 'views';
|
||||
@import 'modals';
|
||||
@import 'app-container';
|
||||
99
cms/static/sass/programs/_components.scss
Normal file
99
cms/static/sass/programs/_components.scss
Normal file
@@ -0,0 +1,99 @@
|
||||
// ------------------------------
|
||||
// Programs: Components
|
||||
|
||||
// About: styling for specific UI components ranging from global to modular.
|
||||
|
||||
// #BUTTONS
|
||||
// #FORMS
|
||||
|
||||
|
||||
// ------------------------------
|
||||
// #BUTTONS
|
||||
// ------------------------------
|
||||
.btn {
|
||||
&.btn-delete,
|
||||
&.btn-edit {
|
||||
border: none;
|
||||
background: none;
|
||||
color: palette(grayscale, base);
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: palette(grayscale, black);
|
||||
}
|
||||
}
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.right {
|
||||
@include float(right);
|
||||
}
|
||||
|
||||
&.btn-create {
|
||||
background: palette(success, base);
|
||||
border-color: palette(success, base);
|
||||
|
||||
// STATE: hover and focus
|
||||
&:hover,
|
||||
&.is-hovered,
|
||||
&:focus,
|
||||
&.is-focused {
|
||||
background: shade($success, 33%);
|
||||
color: $btn-default-focus-color;
|
||||
}
|
||||
|
||||
// STATE: is pressed or active
|
||||
&:active,
|
||||
&.is-pressed,
|
||||
&.is-active {
|
||||
border-color: shade($success, 33%);
|
||||
background: shade($success, 33%);
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon,
|
||||
.text {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// #FORMS
|
||||
// ------------------------------
|
||||
.field {
|
||||
.invalid {
|
||||
border: 2px solid palette(error, base);
|
||||
}
|
||||
|
||||
.field-input,
|
||||
.field-hint,
|
||||
.field-message {
|
||||
min-with: 300px;
|
||||
width: 50%;
|
||||
|
||||
&.is-hidden {
|
||||
@extend .is-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.copy {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
&.bg-white {
|
||||
background-color: palette(grayscale, white);
|
||||
}
|
||||
}
|
||||
76
cms/static/sass/programs/_modals.scss
Normal file
76
cms/static/sass/programs/_modals.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
// ------------------------------
|
||||
// Programs: Modals
|
||||
|
||||
// About: styling for modals.
|
||||
.modal-window-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: palette(grayscale-cool, x-dark);
|
||||
opacity: 0.5;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-window {
|
||||
position: absolute;
|
||||
background-color: palette(grayscale, black);
|
||||
width: 80%;
|
||||
left: 10%;
|
||||
top: 40%;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 5px;
|
||||
padding: 20px;
|
||||
background-color: palette(grayscale-cool, x-dark);
|
||||
border-top: 5px solid palette(warning, base);
|
||||
|
||||
.copy {
|
||||
color: palette(grayscale, white);
|
||||
}
|
||||
|
||||
.emphasized {
|
||||
color: palette(grayscale, white-t);
|
||||
font-weight: font-weight(bold);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: 10px 20px;
|
||||
|
||||
.btn {
|
||||
color: palette(grayscale, white-t);
|
||||
}
|
||||
|
||||
.btn-brand {
|
||||
background: palette(warning, base);
|
||||
border-color: palette(warning, base);
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: palette(warning, dark);
|
||||
border-color: palette(warning, dark);;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-neutral {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
border-color: palette(grayscale-cool, light)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint( $bp-screen-sm ) {
|
||||
.modal-window {
|
||||
width: 440px;
|
||||
left: calc( 50% - 220px );
|
||||
}
|
||||
}
|
||||
62
cms/static/sass/programs/_views.scss
Normal file
62
cms/static/sass/programs/_views.scss
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
// ------------------------------
|
||||
// Programs: Views
|
||||
|
||||
// About: styling for specific views.
|
||||
|
||||
// ------------------------------
|
||||
// #PROGRAM LISTS
|
||||
// ------------------------------
|
||||
.program-list {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
|
||||
.program-details {
|
||||
.name {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
@include float(right);
|
||||
}
|
||||
|
||||
.category {
|
||||
color: palette(grayscale, base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@include clearfix();
|
||||
border-bottom: 1px solid palette(grayscale, base);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.course-container {
|
||||
.subtitle {
|
||||
color: palette(grayscale, base);
|
||||
}
|
||||
}
|
||||
|
||||
.run-container {
|
||||
position: relative;
|
||||
margin: {
|
||||
bottom: 20px;
|
||||
};
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: calc( 100% + 1px );
|
||||
background: palette(grayscale, base);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.course-container {
|
||||
margin: {
|
||||
bottom: 20px;
|
||||
};
|
||||
}
|
||||
@@ -12,3 +12,4 @@ $pattern-library-path: '../edx-pattern-library' !default;
|
||||
|
||||
// Load the shared build
|
||||
@import 'build-v2';
|
||||
@import 'programs/build';
|
||||
|
||||
24
cms/templates/js/programs/confirm_modal.underscore
Normal file
24
cms/templates/js/programs/confirm_modal.underscore
Normal file
@@ -0,0 +1,24 @@
|
||||
<div class="wrapper wrapper-modal-window wrapper-modal-window-<%- name %>"
|
||||
aria-describedby="modal-window-description"
|
||||
aria-labelledby="modal-window-title"
|
||||
aria-hidden=""
|
||||
role="dialog">
|
||||
<div class="modal-window-overlay"></div>
|
||||
<div class="js-focus-first modal-window modal-medium modal-type-confirm" tabindex="-1" aria-labelledby="modal-window-title">
|
||||
<div class="<%- name %>-modal">
|
||||
<div class="modal-content">
|
||||
<span class="copy copy-lead emphasized"><%- title %></span>
|
||||
<p class="copy copy-base"><%- body %></p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<h3 class="sr-only"><%- gettext('Actions') %></h3>
|
||||
<button class="js-confirm btn btn-brand btn-base">
|
||||
<span><%- cta.confirm %></span>
|
||||
</button>
|
||||
<button class="js-cancel btn btn-neutral btn-base">
|
||||
<span><%- cta.cancel %></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
49
cms/templates/js/programs/course_details.underscore
Normal file
49
cms/templates/js/programs/course_details.underscore
Normal file
@@ -0,0 +1,49 @@
|
||||
<div class="card course-container">
|
||||
<% if ( display_name ) { %>
|
||||
<span class="copy copy-large emphasized"><%- display_name %></span>
|
||||
<% if ( status === 'unpublished' ) { %>
|
||||
<button class="js-remove-course btn btn-delete right" data-tooltip="<%- gettext('Delete course') %>">
|
||||
<span class="icon fa fa-trash-o" aria-hidden="true"></span>
|
||||
<span class="sr-only"><%- interpolate(
|
||||
gettext('Remove %(name)s from the program'),
|
||||
{ name: display_name },
|
||||
true
|
||||
) %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
<p class="copy copy-base subtitle"><%- organization.display_name %> / <%- key %>
|
||||
<div class="js-course-runs"></div>
|
||||
<% if ( courseRuns.length > -1 ) { %>
|
||||
<button class="js-add-course-run btn btn-neutral btn-base full">
|
||||
<span class="icon fa fa-plus" aria-hidden="true"></span>
|
||||
<span class="text"><%- gettext('Add another run') %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<form class="form js-course-form">
|
||||
<fieldset class="form-group">
|
||||
<div class="field">
|
||||
<label class="field-label" for="course-key-<%- cid %>"><%- gettext('Course Code') %></label>
|
||||
<input id="course-key-<%- cid %>" class="field-input input-text course-key" name="key" aria-describedby="course-key-<%- cid %>-desc" maxlength="255" required>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
<div class="field-hint" id="course-key-<%- cid %>-desc">
|
||||
<p><%- gettext('The unique number that identifies your course within your organization, e.g. CS101.') %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="display-name-<%- cid %>"><%- gettext('Course Title') %></label>
|
||||
<input id="display-name-<%- cid %>" class="field-input input-text display-name" name="display_name" aria-describedby="display-name-<%- cid %>-desc" maxlength="255" required>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
<div class="field-hint" id="display-name-<%- cid %>-desc">
|
||||
<p><%- gettext('The title entered here will override the title set for the individual run of the course. It will be displayed on the XSeries progress page and in marketing presentations.') %></p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary js-select-course"><%- gettext('Save Course') %></button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
36
cms/templates/js/programs/course_run.underscore
Normal file
36
cms/templates/js/programs/course_run.underscore
Normal file
@@ -0,0 +1,36 @@
|
||||
<div class="card run-container">
|
||||
<% if ( !_.isUndefined(course_key) ) { %>
|
||||
<span class="copy copy-large emphasized"><%- interpolate(
|
||||
gettext('Run %(key)s'),
|
||||
{ key: course_key },
|
||||
true
|
||||
) %></span>
|
||||
<% if ( programStatus === 'unpublished' ) { %>
|
||||
<button class="js-remove-run btn btn-delete right" data-tooltip="<%- gettext('Delete course run') %>">
|
||||
<span class="icon fa fa-trash-o" aria-hidden="true"></span>
|
||||
<span class="sr-only"><%- interpolate(
|
||||
gettext('Remove run %(key)s from the program'),
|
||||
{ key: course_key },
|
||||
true
|
||||
) %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
<div class="copy copy-base subtitle"><%- interpolate(
|
||||
gettext('Start Date: %(date)s'),
|
||||
{ date: start_date },
|
||||
true
|
||||
) %></div>
|
||||
<div class="copy copy-base subtitle"><%- interpolate(
|
||||
gettext('Mode: %(mode)s'),
|
||||
{ mode: mode_slug },
|
||||
true
|
||||
) %></div>
|
||||
<% } else { %>
|
||||
<select class="js-course-run-select">
|
||||
<option><%- gettext('Please select a Course Run') %></option>
|
||||
<% _.each(courseRuns, function(run) { %>
|
||||
<option value="<%- run.id %>"><%- run.name %>: <%- run.id %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
<% } %>
|
||||
</div>
|
||||
65
cms/templates/js/programs/program_creator_form.underscore
Normal file
65
cms/templates/js/programs/program_creator_form.underscore
Normal file
@@ -0,0 +1,65 @@
|
||||
<h3 class="hd-3 emphasized"><%- gettext('Create a New Program') %></h3>
|
||||
<form class="form">
|
||||
<fieldset class="form-group bg-white">
|
||||
<div class="field">
|
||||
<label class="field-label" for="program-type"><%- gettext('Program type') %></label>
|
||||
<select id="program-type" class="field-input input-select program-type" name="category" disabled>
|
||||
<option value="xseries"><%- gettext('XSeries') %></option>
|
||||
</select>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="program-org"><%- gettext('Organization') %></label>
|
||||
<select id="program-org" class="field-input input-select program-org" name="organizations">
|
||||
<option value="false"><%- gettext('Select an organization') %></option>
|
||||
<% _.each( orgs, function( org ) { %>
|
||||
<option value="<%- org.key %>"><%- org.display_name %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="program-name"><%- gettext('Name') %></label>
|
||||
<input id="program-name" class="field-input input-text program-name" name="name" maxlength="64" aria-describedby="program-name-desc" required>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
<div class="field-hint" id="program-name-desc">
|
||||
<p><%- gettext('The public display name of the program.') %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="program-subtitle"><%- gettext('Subtitle') %></label>
|
||||
<input id="program-subtitle" class="field-input input-text program-subtitle" name="subtitle" maxlength="255" aria-describedby="program-subtitle-desc">
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
<div class="field-hint" id="program-subtitle-desc">
|
||||
<p><%- gettext('A short description of the program, including concepts covered and expected outcomes (255 character limit).') %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="program-marketing-slug"><%- gettext('Marketing Slug') %></label>
|
||||
<input id="program-marketing-slug" class="field-input input-text program-marketing-slug" name="marketing_slug" maxlength="255" aria-describedby="program-marketing-slug-desc">
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
<div class="field-hint" id="program-marketing-slug-desc">
|
||||
<p><%- gettext('Slug used to generate links to the marketing site.') %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<button class="btn btn-brand btn-base js-create-program"><%- gettext('Create') %></button>
|
||||
<button class="btn btn-neutral btn-base js-abort-view"><%- gettext('Cancel') %></button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
63
cms/templates/js/programs/program_details.underscore
Normal file
63
cms/templates/js/programs/program_details.underscore
Normal file
@@ -0,0 +1,63 @@
|
||||
<header class="app-header">
|
||||
<form>
|
||||
<div class="layout-col layout-col-b">
|
||||
<div class="js-inline-edit field">
|
||||
<span class="js-model-value copy copy-large emphasized"><%- name %></span>
|
||||
<label for="program-name" class="sr-only"><%- gettext('Name') %></label>
|
||||
<input type="text" value="<%- name %>" id="program-name" class="program-name field-input is-hidden" name="name" data-field="name" maxlength="64" required>
|
||||
<button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program title') %>">
|
||||
<span class="icon fa fa-pencil" aria-hidden="true"></span>
|
||||
<span class="sr-only"><%- gettext('Edit the program\'s name.') %></span>
|
||||
</button>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="js-inline-edit field">
|
||||
<span class="js-model-value copy copy-base subtitle"><%- subtitle %></span>
|
||||
<label for="program-subtitle" class="sr-only"><%- gettext('Subtitle') %></label>
|
||||
<input type="text" value="<%- subtitle %>" id="program-subtitle" class="program-subtitle field-input is-hidden" name="subtitle" data-field="subtitle" maxlength="255">
|
||||
<button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program subtitle') %>">
|
||||
<span class="icon fa fa-pencil" aria-hidden="true"></span>
|
||||
<span class="sr-only"><%- gettext('Edit the program\'s subtitle.') %></span>
|
||||
</button>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="js-inline-edit field">
|
||||
<span class="js-model-value copy copy-base subtitle"><%- marketing_slug %></span>
|
||||
<label for="program-subtitle" class="sr-only"><%- gettext('Marketing Slug') %></label>
|
||||
<input type="text" value="<%- marketing_slug %>" id="program-marketing-slug" class="program-marketing-slug field-input is-hidden" name="marketing_slug" data-field="marketing_slug" maxlength="255">
|
||||
<button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program marketing slug') %>">
|
||||
<span class="icon fa fa-pencil" aria-hidden="true"></span>
|
||||
<span class="sr-only"><%- gettext('Edit the program\'s marketing slug.') %></span>
|
||||
</button>
|
||||
<div class="field-message">
|
||||
<span class="field-message-content"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout-col layout-col-a">
|
||||
<% if ( status === 'unpublished' ) { %>
|
||||
<button class="js-publish-program btn btn-neutral btn-base btn-grey right">
|
||||
<span><%- gettext('Publish') %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
</div>
|
||||
</form>
|
||||
</header>
|
||||
<div class="layout-col layout-col-b">
|
||||
<div class="js-course-list"></div>
|
||||
<% if ( status === 'unpublished' ) { %>
|
||||
<button class="js-add-course btn btn-neutral btn-base full">
|
||||
<span class="icon fa fa-plus" aria-hidden="true"></span>
|
||||
<span class="text"><%- gettext('Add a course') %></span>
|
||||
</button>
|
||||
<% } %>
|
||||
</div>
|
||||
<aside class="js-aside layout-col layout-col-a"></aside>
|
||||
<div class="js-publish-modal"></div>
|
||||
@@ -9,11 +9,11 @@
|
||||
<%block name="title">${_("Program Administration")}</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
<link rel="stylesheet" href=${authoring_app_config.css_url}>
|
||||
<%! main_css = "style-main-v2" %>
|
||||
</%block>
|
||||
|
||||
<%block name="requirejs">
|
||||
require(['${authoring_app_config.js_url | n, js_escaped_string}'], function () {});
|
||||
require(["js/programs/program_admin_app"], function () {});
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
<header class="primary" role="banner">
|
||||
|
||||
<div class="wrapper wrapper-l">
|
||||
<h1 class="branding"><a href="/">
|
||||
<img src="${static.url('images/studio-logo.png')}" alt="${settings.STUDIO_NAME}" />
|
||||
</a></h1>
|
||||
<h1 class="branding">
|
||||
<a class="brand-link" href="/">
|
||||
<img class="brand-image" src="${static.url('images/studio-logo.png')}" alt="${settings.STUDIO_NAME}" />
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
% if context_course:
|
||||
<%
|
||||
@@ -215,33 +217,20 @@
|
||||
% endif
|
||||
% if user.is_authenticated():
|
||||
<nav class="nav-account nav-is-signedin nav-dd ui-right" aria-label="${_('Account')}">
|
||||
<h2 class="sr">${_("Account Navigation")}</h2>
|
||||
<h2 class="sr-only">${_("Account Navigation")}</h2>
|
||||
<ol>
|
||||
<li class="nav-item nav-account-help">
|
||||
<h3 class="title"><span class="label"><a href="${get_online_help_info(online_help_token)['doc_url']}" title="${_('Contextual Online Help')}" target="_blank">${_("Help")}</a></span></h3>
|
||||
</li>
|
||||
<li class="nav-item nav-account-user">
|
||||
<h3 class="title"><span class="label"><span class="label-prefix sr">${_("Currently signed in as:")}</span><span class="account-username" title="${ user.username }">${ user.username }</span></span> <span class="icon fa fa-caret-down ui-toggle-dd" aria-hidden="true"></span></h3>
|
||||
|
||||
<div class="wrapper wrapper-nav-sub">
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
<li class="nav-item nav-account-dashboard">
|
||||
<a href="/">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</a>
|
||||
</li>
|
||||
<li class="nav-item nav-account-signout">
|
||||
<a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<%include file="user_dropdown.html" args="online_help_token=online_help_token" />
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
% else:
|
||||
% else:
|
||||
<nav class="nav-not-signedin nav-pitch" aria-label="${_('Account')}">
|
||||
<h2 class="sr">${_("Account Navigation")}</h2>
|
||||
<h2 class="sr-only">${_("Account Navigation")}</h2>
|
||||
<ol>
|
||||
<li class="nav-item nav-not-signedin-help">
|
||||
<a href="${get_online_help_info(online_help_token)['doc_url']}" title="${_('Contextual Online Help')}" target="_blank">${_("Help")}</a>
|
||||
@@ -254,7 +243,7 @@
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
% endif
|
||||
% endif
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<%page expression_filter="h" args="online_help_token" />
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
<%page args="online_help_token"/>
|
||||
<div class="wrapper-sock wrapper">
|
||||
<ul class="list-actions list-cta">
|
||||
<li class="action-item">
|
||||
@@ -15,8 +15,7 @@ from django.core.urlresolvers import reverse
|
||||
|
||||
<div class="wrapper-inner wrapper">
|
||||
<section class="sock" id="sock" aria-labelledby="sock-heading">
|
||||
<h2 id="sock-heading" class="title sr">${_("{studio_name} Documentation").format(studio_name=settings.STUDIO_NAME)}</h2>
|
||||
|
||||
<h2 id="sock-heading" class="title sr-only">${_("{studio_name} Documentation").format(studio_name=settings.STUDIO_NAME)}</h2>
|
||||
<div class="support">
|
||||
<%!
|
||||
from django.conf import settings
|
||||
|
||||
54
cms/templates/widgets/user_dropdown.html
Normal file
54
cms/templates/widgets/user_dropdown.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
|
||||
% if uses_pattern_library:
|
||||
<div class="wrapper-user-menu dropdown-menu-container logged-in js-header-user-menu">
|
||||
<h3 class="title menu-title">
|
||||
<span class="sr-only">${_("Currently signed in as:")}</span>
|
||||
<span class="account-username" title="${ user.username }">${ user.username }</span>
|
||||
</h3>
|
||||
<button type="button" class="menu-button button-more has-dropdown js-dropdown-button default-icon" aria-haspopup="true" aria-expanded="false" aria-controls="${_("Usermenu")}">
|
||||
<span class="icon-fallback icon-fallback-img">
|
||||
<span class="icon icon-angle-down" aria-hidden="true"></span>
|
||||
<span class="sr-only">${_("Usermenu dropdown")}</span>
|
||||
</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu list-divided is-hidden" id="${_("Usermenu")}" tabindex="-1">
|
||||
<%block name="navigation_dropdown_menu_links" >
|
||||
<li class="dropdown-item item has-block-link">
|
||||
<a href="/">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</a>
|
||||
</li>
|
||||
</%block>
|
||||
<li class="dropdown-item item has-block-link">
|
||||
<a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
% else:
|
||||
<h3 class="title">
|
||||
<span class="label">
|
||||
<span class="label-prefix sr-only">${_("Currently signed in as:")}</span>
|
||||
<span class="account-username" title="${ user.username }">${ user.username }</span>
|
||||
</span>
|
||||
<span class="icon fa fa-caret-down ui-toggle-dd" aria-hidden="true"></span>
|
||||
</h3>
|
||||
|
||||
<div class="wrapper wrapper-nav-sub">
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
<li class="nav-item nav-account-dashboard">
|
||||
<a href="/">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</a>
|
||||
</li>
|
||||
<li class="nav-item nav-account-signout">
|
||||
<a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
@@ -31,16 +31,13 @@ class ProgramsFixture(object):
|
||||
|
||||
class ProgramsConfigMixin(object):
|
||||
"""Mixin providing a method used to configure the programs feature."""
|
||||
def set_programs_api_configuration(self, is_enabled=False, api_version=1, api_url=PROGRAMS_STUB_URL,
|
||||
js_path='/js', css_path='/css'):
|
||||
def set_programs_api_configuration(self, is_enabled=False, api_version=1, api_url=PROGRAMS_STUB_URL):
|
||||
"""Dynamically adjusts the Programs config model during tests."""
|
||||
ConfigModelFixture('/config/programs', {
|
||||
'enabled': is_enabled,
|
||||
'api_version_number': api_version,
|
||||
'internal_service_url': api_url,
|
||||
'public_service_url': api_url,
|
||||
'authoring_app_js_path': js_path,
|
||||
'authoring_app_css_path': css_path,
|
||||
'cache_ttl': 0,
|
||||
'enable_student_dashboard': is_enabled,
|
||||
'enable_studio_tab': is_enabled,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Models providing Programs support for the LMS and Studio."""
|
||||
from collections import namedtuple
|
||||
from urlparse import urljoin
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
@@ -8,9 +7,6 @@ from django.db import models
|
||||
from config_models.models import ConfigurationModel
|
||||
|
||||
|
||||
AuthoringAppConfig = namedtuple('AuthoringAppConfig', ['js_url', 'css_url'])
|
||||
|
||||
|
||||
class ProgramsApiConfig(ConfigurationModel):
|
||||
"""
|
||||
Manages configuration for connecting to the Programs service and using its
|
||||
@@ -25,6 +21,7 @@ class ProgramsApiConfig(ConfigurationModel):
|
||||
internal_service_url = models.URLField(verbose_name=_("Internal Service URL"))
|
||||
public_service_url = models.URLField(verbose_name=_("Public Service URL"))
|
||||
|
||||
# TODO: The property below is obsolete. Delete at the earliest safe moment. See ECOM-4995
|
||||
authoring_app_js_path = models.CharField(
|
||||
verbose_name=_("Path to authoring app's JS"),
|
||||
max_length=255,
|
||||
@@ -33,6 +30,8 @@ class ProgramsApiConfig(ConfigurationModel):
|
||||
"This value is required in order to enable the Studio authoring interface."
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: The property below is obsolete. Delete at the earliest safe moment. See ECOM-4995
|
||||
authoring_app_css_path = models.CharField(
|
||||
verbose_name=_("Path to authoring app's CSS"),
|
||||
max_length=255,
|
||||
@@ -103,17 +102,6 @@ class ProgramsApiConfig(ConfigurationModel):
|
||||
"""
|
||||
return urljoin(self.public_service_url, '/api/v{}/'.format(self.api_version_number))
|
||||
|
||||
@property
|
||||
def authoring_app_config(self):
|
||||
"""
|
||||
Returns a named tuple containing information required for working with the Programs
|
||||
authoring app, a Backbone app hosted by the Programs service.
|
||||
"""
|
||||
js_url = urljoin(self.public_service_url, self.authoring_app_js_path)
|
||||
css_url = urljoin(self.public_service_url, self.authoring_app_css_path)
|
||||
|
||||
return AuthoringAppConfig(js_url=js_url, css_url=css_url)
|
||||
|
||||
@property
|
||||
def is_cache_enabled(self):
|
||||
"""Whether responses from the Programs API will be cached."""
|
||||
@@ -133,12 +121,7 @@ class ProgramsApiConfig(ConfigurationModel):
|
||||
Indicates whether Studio functionality related to Programs should
|
||||
be enabled or not.
|
||||
"""
|
||||
return (
|
||||
self.enabled and
|
||||
self.enable_studio_tab and
|
||||
bool(self.authoring_app_js_path) and
|
||||
bool(self.authoring_app_css_path)
|
||||
)
|
||||
return self.enabled and self.enable_studio_tab
|
||||
|
||||
@property
|
||||
def is_certification_enabled(self):
|
||||
|
||||
@@ -15,8 +15,6 @@ class ProgramsApiConfigMixin(object):
|
||||
'api_version_number': 1,
|
||||
'internal_service_url': 'http://internal.programs.org/',
|
||||
'public_service_url': 'http://public.programs.org/',
|
||||
'authoring_app_js_path': '/path/to/js',
|
||||
'authoring_app_css_path': '/path/to/css',
|
||||
'cache_ttl': 0,
|
||||
'enable_student_dashboard': True,
|
||||
'enable_studio_tab': True,
|
||||
|
||||
@@ -26,17 +26,6 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
|
||||
programs_config.public_service_url.strip('/') + '/api/v{}/'.format(programs_config.api_version_number)
|
||||
)
|
||||
|
||||
authoring_app_config = programs_config.authoring_app_config
|
||||
|
||||
self.assertEqual(
|
||||
authoring_app_config.js_url,
|
||||
programs_config.public_service_url.strip('/') + programs_config.authoring_app_js_path
|
||||
)
|
||||
self.assertEqual(
|
||||
authoring_app_config.css_url,
|
||||
programs_config.public_service_url.strip('/') + programs_config.authoring_app_css_path
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
(0, False),
|
||||
(1, True),
|
||||
@@ -72,9 +61,6 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
|
||||
programs_config = self.create_programs_config(enable_studio_tab=False)
|
||||
self.assertFalse(programs_config.is_studio_tab_enabled)
|
||||
|
||||
programs_config = self.create_programs_config(authoring_app_js_path='', authoring_app_css_path='')
|
||||
self.assertFalse(programs_config.is_studio_tab_enabled)
|
||||
|
||||
programs_config = self.create_programs_config()
|
||||
self.assertTrue(programs_config.is_studio_tab_enabled)
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ from openedx.core.lib.edx_api_utils import get_edx_api_data
|
||||
from student.models import CourseEnrollment
|
||||
from util.date_utils import strftime_localized
|
||||
from util.organizations_helpers import get_organization_by_short_name
|
||||
from xmodule.course_metadata_utils import DEFAULT_START_DATE
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"backbone": "~1.3.2",
|
||||
"backbone-validation": "~0.11.5",
|
||||
"coffee-script": "1.6.1",
|
||||
"edx-pattern-library": "0.16.0",
|
||||
"edx-pattern-library": "0.16.1",
|
||||
"edx-ui-toolkit": "1.4.1",
|
||||
"jquery": "~2.2.0",
|
||||
"jquery-migrate": "^1.4.1",
|
||||
|
||||
@@ -53,6 +53,7 @@ NPM_INSTALLED_LIBRARIES = [
|
||||
'picturefill/dist/picturefill.js',
|
||||
'backbone/backbone.js',
|
||||
'edx-ui-toolkit/node_modules/backbone.paginator/lib/backbone.paginator.js',
|
||||
'backbone-validation/dist/backbone-validation-min.js',
|
||||
]
|
||||
|
||||
# Directory to install static vendor files
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<%page expression_filter="h" args="online_help_token"/>
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.core.urlresolvers import reverse
|
||||
%>
|
||||
<%page args="online_help_token"/>
|
||||
<div class="wrapper-sock wrapper">
|
||||
<ul class="list-actions list-cta">
|
||||
<li class="action-item">
|
||||
@@ -16,7 +16,7 @@ from django.core.urlresolvers import reverse
|
||||
<div class="wrapper-inner wrapper">
|
||||
<section class="sock" id="sock">
|
||||
<header>
|
||||
<h2 class="title sr">${_("{studio_name} Documentation").format(studio_name=settings.STUDIO_NAME)}</h2>
|
||||
<h2 class="title sr-only">${_("{studio_name} Documentation").format(studio_name=settings.STUDIO_NAME)}</h2>
|
||||
</header>
|
||||
|
||||
<div class="support">
|
||||
|
||||
Reference in New Issue
Block a user