Commit 1d768cde by Simon Chen Committed by GitHub

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

parent 7745e7cd
......@@ -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',
......
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) {
......
define([
'backbone',
'js/programs/utils/auth_utils'
],
function( Backbone, auth ) {
'use strict';
return Backbone.Collection.extend(auth.autoSync);
}
);
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);
}
});
}
);
define([
'backbone',
'jquery',
'js/programs/models/program_model'
],
function( Backbone, $, ProgramModel ) {
'use strict';
return Backbone.Collection.extend({
model: ProgramModel
});
}
);
define([
'backbone'
],
function( Backbone ) {
'use strict';
return Backbone.Model.extend({
defaults: {
username: '',
lmsBaseUrl: '',
programsApiUrl: '',
authUrl: '/programs/id_token/',
idToken: ''
}
});
}
);
define([
'backbone',
'js/programs/utils/auth_utils'
],
function( Backbone, auth ) {
'use strict';
return Backbone.Model.extend(auth.autoSync);
}
);
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: []
}
});
}
);
define([
'backbone'
],
function( Backbone ) {
'use strict';
return Backbone.Model.extend({
defaults: {
course_key: '',
mode_slug: 'verified',
sku: '',
start_date: '',
run_key: ''
}
});
}
);
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';
}
});
}
);
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 );
}
});
}
);
(function() {
'use strict';
require([
'js/programs/views/program_admin_app_view'
],
function( ProgramAdminAppView ) {
return new ProgramAdminAppView();
}
);
})();
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;
}
});
}
);
/**
* 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;
})();
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;
}
);
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;
}
);
/**
* Reusable constants
*/
define([], function() {
'use strict';
return {
keyCodes: {
tab: 9,
enter: 13,
esc: 27,
up: 38,
down: 40
}
};
});
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'
});
}
);
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();
}
}
});
}
);
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 });
}
}
});
}
);
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();
}
}
});
}
);
(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 } );
}
}
});
}
);
})();
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' ) ) );
}
});
}
);
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;
}
});
}
);
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();
});
});
}
);
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();
});
});
}
);
// 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';
// pill button
%ui-btn-pill {
border-radius: ($baseline/5);
}
// +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;
}
}
}
// 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);
}
}
// 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;
}
}
// 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);
}
}
}
.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;
}
}
.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);
}
}
\ No newline at end of file
......@@ -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);
......
// ------------------------------
// Programs: App Container
// About: styling for setting up the wrapper.
.program-app {
&.layout-1q3q {
max-width: 1250px;
}
}
// ------------------------------
// Programs: Main Style Compile
// About: Sass compile for the Programs IDA.
@import 'components';
@import 'views';
@import 'modals';
@import 'app-container';
// ------------------------------
// 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);
}
}
// ------------------------------
// 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 );
}
}
// ------------------------------
// 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';
<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>
<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>
<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>
<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>
<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:
<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>
......
<%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
......
<%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
\ No newline at end of file
......@@ -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,
......
"""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,
......@@ -104,17 +103,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."""
return self.cache_ttl > 0
......@@ -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
......
<%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">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment