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): ...@@ -28,12 +28,11 @@ class ProgramAuthoringView(View):
if programs_config.is_studio_tab_enabled and request.user.is_staff: if programs_config.is_studio_tab_enabled and request.user.is_staff:
return render_to_response('program_authoring.html', { 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), 'lms_base_url': '//{}/'.format(settings.LMS_BASE),
'programs_api_url': programs_config.public_api_url, 'programs_api_url': programs_config.public_api_url,
'programs_token_url': reverse('programs_id_token'), 'programs_token_url': reverse('programs_id_token'),
'studio_home_url': reverse('home'), 'studio_home_url': reverse('home'),
'uses_pattern_library': True
}) })
else: else:
raise Http404 raise Http404
......
...@@ -56,6 +56,7 @@ ...@@ -56,6 +56,7 @@
'underscore.string': 'common/js/vendor/underscore.string', 'underscore.string': 'common/js/vendor/underscore.string',
'backbone': 'common/js/vendor/backbone', 'backbone': 'common/js/vendor/backbone',
'backbone-relational': 'js/vendor/backbone-relational.min', 'backbone-relational': 'js/vendor/backbone-relational.min',
'backbone.validation': 'common/js/vendor/backbone-validation-min',
'backbone.associations': 'js/vendor/backbone-associations-min', 'backbone.associations': 'js/vendor/backbone-associations-min',
'backbone.paginator': 'common/js/vendor/backbone.paginator', 'backbone.paginator': 'common/js/vendor/backbone.paginator',
'tinymce': 'js/vendor/tinymce/js/tinymce/tinymce.full.min', 'tinymce': 'js/vendor/tinymce/js/tinymce/tinymce.full.min',
......
...@@ -38,6 +38,7 @@ ...@@ -38,6 +38,7 @@
'backbone': 'common/js/vendor/backbone', 'backbone': 'common/js/vendor/backbone',
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min', 'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
'backbone.paginator': 'common/js/vendor/backbone.paginator', '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', 'backbone-relational': 'xmodule_js/common_static/js/vendor/backbone-relational.min',
'tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/tinymce.full.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', 'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
...@@ -267,7 +268,10 @@ ...@@ -267,7 +268,10 @@
'js/certificates/spec/views/certificate_details_spec', 'js/certificates/spec/views/certificate_details_spec',
'js/certificates/spec/views/certificate_editor_spec', 'js/certificates/spec/views/certificate_editor_spec',
'js/certificates/spec/views/certificates_list_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; i = 0;
......
...@@ -34,6 +34,7 @@ ...@@ -34,6 +34,7 @@
'backbone': 'common/js/vendor/backbone', 'backbone': 'common/js/vendor/backbone',
'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min', 'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
'backbone.paginator': 'common/js/vendor/backbone.paginator', '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', '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', 'jquery.tinymce': 'xmodule_js/common_static/js/vendor/tinymce/js/tinymce/jquery.tinymce',
'xmodule': 'xmodule_js/src/xmodule', 'xmodule': 'xmodule_js/src/xmodule',
......
require(["domReady", "jquery", "underscore", "gettext", "common/js/components/views/feedback_notification", require([
"common/js/components/views/feedback_prompt", "js/utils/date_utils", "domReady",
"js/utils/module", "js/utils/handle_iframe_binding", "jquery.ui", "jquery.leanModal", "jquery",
"jquery.form", "jquery.smoothScroll"], "underscore",
function(domReady, $, _, gettext, NotificationView, PromptView, DateUtils, ModuleUtils, IframeUtils) "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; var $body;
domReady(function() { domReady(function() {
var dropdownMenuView;
$body = $('body'); $body = $('body');
$body.on('click', '.embeddable-xml-input', function() { $body.on('click', '.embeddable-xml-input', function() {
...@@ -67,6 +92,14 @@ domReady(function() { ...@@ -67,6 +92,14 @@ domReady(function() {
if ($.browser.msie) { if ($.browser.msie) {
$.ajaxSetup({ cache: false }); $.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) { 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 { ...@@ -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 // +Utility - Basic
// ==================== // ====================
......
...@@ -45,6 +45,7 @@ ...@@ -45,6 +45,7 @@
@import 'elements/modal-window'; @import 'elements/modal-window';
@import 'elements/uploaded-assets'; // layout for asset tables @import 'elements/uploaded-assets'; // layout for asset tables
@import 'elements/creative-commons'; @import 'elements/creative-commons';
@import 'elements/tooltip';
// +Base - Specific Views // +Base - Specific Views
// ==================== // ====================
......
...@@ -7,3 +7,13 @@ ...@@ -7,3 +7,13 @@
@import 'config'; @import 'config';
// Extensions // 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); ...@@ -46,6 +46,7 @@ $black-t0: rgba($black, 0.125);
$black-t1: rgba($black, 0.25); $black-t1: rgba($black, 0.25);
$black-t2: rgba($black, 0.5); $black-t2: rgba($black, 0.5);
$black-t3: rgba($black, 0.75); $black-t3: rgba($black, 0.75);
$black-t4: rgba($black, 0.85);
$white: rgb(255,255,255); $white: rgb(255,255,255);
$white-t0: rgba($white, 0.125); $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; ...@@ -12,3 +12,4 @@ $pattern-library-path: '../edx-pattern-library' !default;
// Load the shared build // Load the shared build
@import 'build-v2'; @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 @@ ...@@ -9,11 +9,11 @@
<%block name="title">${_("Program Administration")}</%block> <%block name="title">${_("Program Administration")}</%block>
<%block name="header_extras"> <%block name="header_extras">
<link rel="stylesheet" href=${authoring_app_config.css_url}> <%! main_css = "style-main-v2" %>
</%block> </%block>
<%block name="requirejs"> <%block name="requirejs">
require(['${authoring_app_config.js_url | n, js_escaped_string}'], function () {}); require(["js/programs/program_admin_app"], function () {});
</%block> </%block>
<%block name="content"> <%block name="content">
......
...@@ -10,9 +10,11 @@ ...@@ -10,9 +10,11 @@
<header class="primary" role="banner"> <header class="primary" role="banner">
<div class="wrapper wrapper-l"> <div class="wrapper wrapper-l">
<h1 class="branding"><a href="/"> <h1 class="branding">
<img src="${static.url('images/studio-logo.png')}" alt="${settings.STUDIO_NAME}" /> <a class="brand-link" href="/">
</a></h1> <img class="brand-image" src="${static.url('images/studio-logo.png')}" alt="${settings.STUDIO_NAME}" />
</a>
</h1>
% if context_course: % if context_course:
<% <%
...@@ -215,33 +217,20 @@ ...@@ -215,33 +217,20 @@
% endif % endif
% if user.is_authenticated(): % if user.is_authenticated():
<nav class="nav-account nav-is-signedin nav-dd ui-right" aria-label="${_('Account')}"> <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> <ol>
<li class="nav-item nav-account-help"> <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> <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>
<li class="nav-item nav-account-user"> <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> <%include file="user_dropdown.html" args="online_help_token=online_help_token" />
<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>
</li> </li>
</ol> </ol>
</nav> </nav>
% else: % else:
<nav class="nav-not-signedin nav-pitch" aria-label="${_('Account')}"> <nav class="nav-not-signedin nav-pitch" aria-label="${_('Account')}">
<h2 class="sr">${_("Account Navigation")}</h2> <h2 class="sr-only">${_("Account Navigation")}</h2>
<ol> <ol>
<li class="nav-item nav-not-signedin-help"> <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> <a href="${get_online_help_info(online_help_token)['doc_url']}" title="${_('Contextual Online Help')}" target="_blank">${_("Help")}</a>
...@@ -254,7 +243,7 @@ ...@@ -254,7 +243,7 @@
</li> </li>
</ol> </ol>
</nav> </nav>
% endif % endif
</div> </div>
</header> </header>
</div> </div>
<%page expression_filter="h" args="online_help_token" />
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
%> %>
<%page args="online_help_token"/>
<div class="wrapper-sock wrapper"> <div class="wrapper-sock wrapper">
<ul class="list-actions list-cta"> <ul class="list-actions list-cta">
<li class="action-item"> <li class="action-item">
...@@ -15,8 +15,7 @@ from django.core.urlresolvers import reverse ...@@ -15,8 +15,7 @@ from django.core.urlresolvers import reverse
<div class="wrapper-inner wrapper"> <div class="wrapper-inner wrapper">
<section class="sock" id="sock" aria-labelledby="sock-heading"> <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"> <div class="support">
<%! <%!
from django.conf import settings 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): ...@@ -31,16 +31,13 @@ class ProgramsFixture(object):
class ProgramsConfigMixin(object): class ProgramsConfigMixin(object):
"""Mixin providing a method used to configure the programs feature.""" """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, def set_programs_api_configuration(self, is_enabled=False, api_version=1, api_url=PROGRAMS_STUB_URL):
js_path='/js', css_path='/css'):
"""Dynamically adjusts the Programs config model during tests.""" """Dynamically adjusts the Programs config model during tests."""
ConfigModelFixture('/config/programs', { ConfigModelFixture('/config/programs', {
'enabled': is_enabled, 'enabled': is_enabled,
'api_version_number': api_version, 'api_version_number': api_version,
'internal_service_url': api_url, 'internal_service_url': api_url,
'public_service_url': api_url, 'public_service_url': api_url,
'authoring_app_js_path': js_path,
'authoring_app_css_path': css_path,
'cache_ttl': 0, 'cache_ttl': 0,
'enable_student_dashboard': is_enabled, 'enable_student_dashboard': is_enabled,
'enable_studio_tab': is_enabled, 'enable_studio_tab': is_enabled,
......
"""Models providing Programs support for the LMS and Studio.""" """Models providing Programs support for the LMS and Studio."""
from collections import namedtuple
from urlparse import urljoin from urlparse import urljoin
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
...@@ -8,9 +7,6 @@ from django.db import models ...@@ -8,9 +7,6 @@ from django.db import models
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
AuthoringAppConfig = namedtuple('AuthoringAppConfig', ['js_url', 'css_url'])
class ProgramsApiConfig(ConfigurationModel): class ProgramsApiConfig(ConfigurationModel):
""" """
Manages configuration for connecting to the Programs service and using its Manages configuration for connecting to the Programs service and using its
...@@ -25,6 +21,7 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -25,6 +21,7 @@ class ProgramsApiConfig(ConfigurationModel):
internal_service_url = models.URLField(verbose_name=_("Internal Service URL")) internal_service_url = models.URLField(verbose_name=_("Internal Service URL"))
public_service_url = models.URLField(verbose_name=_("Public 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( authoring_app_js_path = models.CharField(
verbose_name=_("Path to authoring app's JS"), verbose_name=_("Path to authoring app's JS"),
max_length=255, max_length=255,
...@@ -33,6 +30,8 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -33,6 +30,8 @@ class ProgramsApiConfig(ConfigurationModel):
"This value is required in order to enable the Studio authoring interface." "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( authoring_app_css_path = models.CharField(
verbose_name=_("Path to authoring app's CSS"), verbose_name=_("Path to authoring app's CSS"),
max_length=255, max_length=255,
...@@ -104,17 +103,6 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -104,17 +103,6 @@ class ProgramsApiConfig(ConfigurationModel):
return urljoin(self.public_service_url, '/api/v{}/'.format(self.api_version_number)) return urljoin(self.public_service_url, '/api/v{}/'.format(self.api_version_number))
@property @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): def is_cache_enabled(self):
"""Whether responses from the Programs API will be cached.""" """Whether responses from the Programs API will be cached."""
return self.cache_ttl > 0 return self.cache_ttl > 0
...@@ -133,12 +121,7 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -133,12 +121,7 @@ class ProgramsApiConfig(ConfigurationModel):
Indicates whether Studio functionality related to Programs should Indicates whether Studio functionality related to Programs should
be enabled or not. be enabled or not.
""" """
return ( return self.enabled and self.enable_studio_tab
self.enabled and
self.enable_studio_tab and
bool(self.authoring_app_js_path) and
bool(self.authoring_app_css_path)
)
@property @property
def is_certification_enabled(self): def is_certification_enabled(self):
......
...@@ -15,8 +15,6 @@ class ProgramsApiConfigMixin(object): ...@@ -15,8 +15,6 @@ class ProgramsApiConfigMixin(object):
'api_version_number': 1, 'api_version_number': 1,
'internal_service_url': 'http://internal.programs.org/', 'internal_service_url': 'http://internal.programs.org/',
'public_service_url': 'http://public.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, 'cache_ttl': 0,
'enable_student_dashboard': True, 'enable_student_dashboard': True,
'enable_studio_tab': True, 'enable_studio_tab': True,
......
...@@ -26,17 +26,6 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase): ...@@ -26,17 +26,6 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
programs_config.public_service_url.strip('/') + '/api/v{}/'.format(programs_config.api_version_number) 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( @ddt.data(
(0, False), (0, False),
(1, True), (1, True),
...@@ -72,9 +61,6 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase): ...@@ -72,9 +61,6 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase):
programs_config = self.create_programs_config(enable_studio_tab=False) programs_config = self.create_programs_config(enable_studio_tab=False)
self.assertFalse(programs_config.is_studio_tab_enabled) 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() programs_config = self.create_programs_config()
self.assertTrue(programs_config.is_studio_tab_enabled) self.assertTrue(programs_config.is_studio_tab_enabled)
......
...@@ -20,7 +20,6 @@ from openedx.core.lib.edx_api_utils import get_edx_api_data ...@@ -20,7 +20,6 @@ from openedx.core.lib.edx_api_utils import get_edx_api_data
from student.models import CourseEnrollment from student.models import CourseEnrollment
from util.date_utils import strftime_localized from util.date_utils import strftime_localized
from util.organizations_helpers import get_organization_by_short_name from util.organizations_helpers import get_organization_by_short_name
from xmodule.course_metadata_utils import DEFAULT_START_DATE
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
......
...@@ -3,8 +3,9 @@ ...@@ -3,8 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"backbone": "~1.3.2", "backbone": "~1.3.2",
"backbone-validation": "~0.11.5",
"coffee-script": "1.6.1", "coffee-script": "1.6.1",
"edx-pattern-library": "0.16.0", "edx-pattern-library": "0.16.1",
"edx-ui-toolkit": "1.4.1", "edx-ui-toolkit": "1.4.1",
"jquery": "~2.2.0", "jquery": "~2.2.0",
"jquery-migrate": "^1.4.1", "jquery-migrate": "^1.4.1",
......
...@@ -53,6 +53,7 @@ NPM_INSTALLED_LIBRARIES = [ ...@@ -53,6 +53,7 @@ NPM_INSTALLED_LIBRARIES = [
'picturefill/dist/picturefill.js', 'picturefill/dist/picturefill.js',
'backbone/backbone.js', 'backbone/backbone.js',
'edx-ui-toolkit/node_modules/backbone.paginator/lib/backbone.paginator.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 # Directory to install static vendor files
......
<%page expression_filter="h" args="online_help_token"/>
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
%> %>
<%page args="online_help_token"/>
<div class="wrapper-sock wrapper"> <div class="wrapper-sock wrapper">
<ul class="list-actions list-cta"> <ul class="list-actions list-cta">
<li class="action-item"> <li class="action-item">
...@@ -16,7 +16,7 @@ from django.core.urlresolvers import reverse ...@@ -16,7 +16,7 @@ from django.core.urlresolvers import reverse
<div class="wrapper-inner wrapper"> <div class="wrapper-inner wrapper">
<section class="sock" id="sock"> <section class="sock" id="sock">
<header> <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> </header>
<div class="support"> <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