From 1d768cde7a08292064428e20804898fed06a737c Mon Sep 17 00:00:00 2001 From: Simon Chen <schen@edx.org> Date: Tue, 19 Jul 2016 19:09:51 -0400 Subject: [PATCH] ECOM-4904 Move the program editor backbone app to Studio (#12962) --- cms/djangoapps/contentstore/views/program.py | 3 +- cms/static/cms/js/require-config.js | 1 + cms/static/cms/js/spec/main.js | 6 +- cms/static/cms/js/spec/main_squire.js | 1 + cms/static/js/base.js | 43 +- .../collections/auto_auth_collection.js | 10 + .../collections/course_runs_collection.js | 59 ++ .../collections/programs_collection.js | 13 + .../js/programs/models/api_config_model.js | 17 + .../js/programs/models/auto_auth_model.js | 10 + cms/static/js/programs/models/course_model.js | 38 ++ .../js/programs/models/course_run_model.js | 17 + .../js/programs/models/organizations_model.js | 16 + .../js/programs/models/program_model.js | 106 ++++ cms/static/js/programs/program_admin_app.js | 10 + cms/static/js/programs/router.js | 65 +++ cms/static/js/programs/shims/gettext.js | 22 + cms/static/js/programs/utils/api_config.js | 21 + cms/static/js/programs/utils/auth_utils.js | 89 +++ cms/static/js/programs/utils/constants.js | 16 + .../js/programs/utils/validation_config.js | 70 +++ .../js/programs/views/confirm_modal_view.js | 59 ++ .../js/programs/views/course_details_view.js | 204 +++++++ .../js/programs/views/course_run_view.js | 113 ++++ .../programs/views/program_admin_app_view.js | 68 +++ .../js/programs/views/program_creator_view.js | 112 ++++ .../js/programs/views/program_details_view.js | 207 +++++++ .../js/spec/models/auto_auth_model_spec.js | 116 ++++ .../views/programs/program_creator_spec.js | 217 +++++++ .../views/programs/program_details_spec.js | 533 ++++++++++++++++++ cms/static/sass/_base-v2.scss | 15 + cms/static/sass/_base.scss | 29 - cms/static/sass/_build-v1.scss | 1 + cms/static/sass/_build-v2.scss | 10 + cms/static/sass/_mixins-v2.scss | 4 + cms/static/sass/elements-v2/_controls.scss | 45 ++ cms/static/sass/elements-v2/_header.scss | 172 ++++++ cms/static/sass/elements-v2/_navigation.scss | 22 + cms/static/sass/elements-v2/_sock.scss | 135 +++++ cms/static/sass/elements-v2/_tooltip.scss | 25 + cms/static/sass/elements/_tooltip.scss | 27 + cms/static/sass/partials/_variables.scss | 1 + cms/static/sass/programs/_app-container.scss | 10 + cms/static/sass/programs/_build.scss | 9 + cms/static/sass/programs/_components.scss | 99 ++++ cms/static/sass/programs/_modals.scss | 76 +++ cms/static/sass/programs/_views.scss | 62 ++ cms/static/sass/studio-main-v2.scss | 1 + .../js/programs/confirm_modal.underscore | 24 + .../js/programs/course_details.underscore | 49 ++ .../js/programs/course_run.underscore | 36 ++ .../programs/program_creator_form.underscore | 65 +++ .../js/programs/program_details.underscore | 63 +++ cms/templates/program_authoring.html | 4 +- cms/templates/widgets/header.html | 31 +- cms/templates/widgets/sock.html | 5 +- cms/templates/widgets/user_dropdown.html | 54 ++ common/test/acceptance/fixtures/programs.py | 5 +- openedx/core/djangoapps/programs/models.py | 25 +- .../core/djangoapps/programs/tests/mixins.py | 2 - .../djangoapps/programs/tests/test_models.py | 14 - openedx/core/djangoapps/programs/utils.py | 1 - package.json | 3 +- pavelib/assets.py | 1 + .../edx.org/cms/templates/widgets/sock.html | 4 +- 65 files changed, 3283 insertions(+), 108 deletions(-) create mode 100644 cms/static/js/programs/collections/auto_auth_collection.js create mode 100644 cms/static/js/programs/collections/course_runs_collection.js create mode 100644 cms/static/js/programs/collections/programs_collection.js create mode 100644 cms/static/js/programs/models/api_config_model.js create mode 100644 cms/static/js/programs/models/auto_auth_model.js create mode 100644 cms/static/js/programs/models/course_model.js create mode 100644 cms/static/js/programs/models/course_run_model.js create mode 100644 cms/static/js/programs/models/organizations_model.js create mode 100644 cms/static/js/programs/models/program_model.js create mode 100644 cms/static/js/programs/program_admin_app.js create mode 100644 cms/static/js/programs/router.js create mode 100644 cms/static/js/programs/shims/gettext.js create mode 100644 cms/static/js/programs/utils/api_config.js create mode 100644 cms/static/js/programs/utils/auth_utils.js create mode 100644 cms/static/js/programs/utils/constants.js create mode 100644 cms/static/js/programs/utils/validation_config.js create mode 100644 cms/static/js/programs/views/confirm_modal_view.js create mode 100644 cms/static/js/programs/views/course_details_view.js create mode 100644 cms/static/js/programs/views/course_run_view.js create mode 100644 cms/static/js/programs/views/program_admin_app_view.js create mode 100644 cms/static/js/programs/views/program_creator_view.js create mode 100644 cms/static/js/programs/views/program_details_view.js create mode 100644 cms/static/js/spec/models/auto_auth_model_spec.js create mode 100644 cms/static/js/spec/views/programs/program_creator_spec.js create mode 100644 cms/static/js/spec/views/programs/program_details_spec.js create mode 100644 cms/static/sass/_base-v2.scss create mode 100644 cms/static/sass/_mixins-v2.scss create mode 100644 cms/static/sass/elements-v2/_controls.scss create mode 100644 cms/static/sass/elements-v2/_header.scss create mode 100644 cms/static/sass/elements-v2/_navigation.scss create mode 100644 cms/static/sass/elements-v2/_sock.scss create mode 100644 cms/static/sass/elements-v2/_tooltip.scss create mode 100644 cms/static/sass/elements/_tooltip.scss create mode 100644 cms/static/sass/programs/_app-container.scss create mode 100644 cms/static/sass/programs/_build.scss create mode 100644 cms/static/sass/programs/_components.scss create mode 100644 cms/static/sass/programs/_modals.scss create mode 100644 cms/static/sass/programs/_views.scss create mode 100644 cms/templates/js/programs/confirm_modal.underscore create mode 100644 cms/templates/js/programs/course_details.underscore create mode 100644 cms/templates/js/programs/course_run.underscore create mode 100644 cms/templates/js/programs/program_creator_form.underscore create mode 100644 cms/templates/js/programs/program_details.underscore create mode 100644 cms/templates/widgets/user_dropdown.html diff --git a/cms/djangoapps/contentstore/views/program.py b/cms/djangoapps/contentstore/views/program.py index eceeb18f945..d95f10b9c5b 100644 --- a/cms/djangoapps/contentstore/views/program.py +++ b/cms/djangoapps/contentstore/views/program.py @@ -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 diff --git a/cms/static/cms/js/require-config.js b/cms/static/cms/js/require-config.js index 3c84a8d3df0..dae81e88f07 100644 --- a/cms/static/cms/js/require-config.js +++ b/cms/static/cms/js/require-config.js @@ -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', diff --git a/cms/static/cms/js/spec/main.js b/cms/static/cms/js/spec/main.js index bed7aa8ee0a..5b8057f253c 100644 --- a/cms/static/cms/js/spec/main.js +++ b/cms/static/cms/js/spec/main.js @@ -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; diff --git a/cms/static/cms/js/spec/main_squire.js b/cms/static/cms/js/spec/main_squire.js index ae59108d33c..89e19ab9833 100644 --- a/cms/static/cms/js/spec/main_squire.js +++ b/cms/static/cms/js/spec/main_squire.js @@ -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', diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 4ab1c9a5057..94c6cc26487 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -1,13 +1,38 @@ -require(["domReady", "jquery", "underscore", "gettext", "common/js/components/views/feedback_notification", - "common/js/components/views/feedback_prompt", "js/utils/date_utils", - "js/utils/module", "js/utils/handle_iframe_binding", "jquery.ui", "jquery.leanModal", - "jquery.form", "jquery.smoothScroll"], - function(domReady, $, _, gettext, NotificationView, PromptView, DateUtils, ModuleUtils, IframeUtils) +require([ + "domReady", + "jquery", + "underscore", + "gettext", + "common/js/components/views/feedback_notification", + "common/js/components/views/feedback_prompt", + "js/utils/date_utils", + "js/utils/module", + "js/utils/handle_iframe_binding", + "edx-ui-toolkit/js/dropdown-menu/dropdown-menu-view", + "jquery.ui", + "jquery.leanModal", + "jquery.form", + "jquery.smoothScroll" + ], + function( + domReady, + $, + _, + gettext, + NotificationView, + PromptView, + DateUtils, + ModuleUtils, + IframeUtils, + DropdownMenuView + ) { var $body; domReady(function() { + var dropdownMenuView; + $body = $('body'); $body.on('click', '.embeddable-xml-input', function() { @@ -67,6 +92,14 @@ domReady(function() { if ($.browser.msie) { $.ajaxSetup({ cache: false }); } + + //Initiate the edx tool kit dropdown menu + if ($('.js-header-user-menu').length){ + dropdownMenuView = new DropdownMenuView({ + el: '.js-header-user-menu' + }); + dropdownMenuView.postRender(); + } }); function smoothScrollLink(e) { diff --git a/cms/static/js/programs/collections/auto_auth_collection.js b/cms/static/js/programs/collections/auto_auth_collection.js new file mode 100644 index 00000000000..2d768df1150 --- /dev/null +++ b/cms/static/js/programs/collections/auto_auth_collection.js @@ -0,0 +1,10 @@ +define([ + 'backbone', + 'js/programs/utils/auth_utils' + ], + function( Backbone, auth ) { + 'use strict'; + + return Backbone.Collection.extend(auth.autoSync); + } +); diff --git a/cms/static/js/programs/collections/course_runs_collection.js b/cms/static/js/programs/collections/course_runs_collection.js new file mode 100644 index 00000000000..71567b1bf1c --- /dev/null +++ b/cms/static/js/programs/collections/course_runs_collection.js @@ -0,0 +1,59 @@ +define([ + 'backbone', + 'jquery', + 'js/programs/utils/api_config', + 'js/programs/collections/auto_auth_collection', + 'jquery.cookie' + ], + function( Backbone, $, apiConfig, AutoAuthCollection ) { + 'use strict'; + + return AutoAuthCollection.extend({ + allRuns: [], + + initialize: function(models, options) { + // Ignore pagination and give me everything + var orgStr = options.organization.key, + queries = '?org=' + orgStr + '&username=' + apiConfig.get('username') + '&page_size=1000'; + + this.url = apiConfig.get('lmsBaseUrl') + 'api/courses/v1/courses/' + queries; + }, + + /* + * Abridged version of Backbone.Collection.Create that does not + * save the updated Collection back to the server + * (code based on original function - http://backbonejs.org/docs/backbone.html#section-134) + */ + create: function(model, options) { + options = options ? _.clone(options) : {}; + model = this._prepareModel(model, options); + + if (!!model) { + this.add(model, options); + return model; + } + }, + + parse: function(data) { + this.allRuns = data.results; + + // Because pagination is ignored just set results + return data.results; + }, + + // Adds a run back into the model for selection + addRun: function(id) { + var courseRun = _.findWhere( this.allRuns, { id: id }); + + this.create(courseRun); + }, + + // Removes a run from the model for selection + removeRun: function(id) { + var courseRun = this.where({id: id}); + + this.remove(courseRun); + } + }); + } +); diff --git a/cms/static/js/programs/collections/programs_collection.js b/cms/static/js/programs/collections/programs_collection.js new file mode 100644 index 00000000000..da23f30c96b --- /dev/null +++ b/cms/static/js/programs/collections/programs_collection.js @@ -0,0 +1,13 @@ +define([ + 'backbone', + 'jquery', + 'js/programs/models/program_model' + ], + function( Backbone, $, ProgramModel ) { + 'use strict'; + + return Backbone.Collection.extend({ + model: ProgramModel + }); + } +); diff --git a/cms/static/js/programs/models/api_config_model.js b/cms/static/js/programs/models/api_config_model.js new file mode 100644 index 00000000000..55fcd703169 --- /dev/null +++ b/cms/static/js/programs/models/api_config_model.js @@ -0,0 +1,17 @@ +define([ + 'backbone' + ], + function( Backbone ) { + 'use strict'; + + return Backbone.Model.extend({ + defaults: { + username: '', + lmsBaseUrl: '', + programsApiUrl: '', + authUrl: '/programs/id_token/', + idToken: '' + } + }); + } +); diff --git a/cms/static/js/programs/models/auto_auth_model.js b/cms/static/js/programs/models/auto_auth_model.js new file mode 100644 index 00000000000..8a61e3a8ade --- /dev/null +++ b/cms/static/js/programs/models/auto_auth_model.js @@ -0,0 +1,10 @@ +define([ + 'backbone', + 'js/programs/utils/auth_utils' + ], + function( Backbone, auth ) { + 'use strict'; + + return Backbone.Model.extend(auth.autoSync); + } +); diff --git a/cms/static/js/programs/models/course_model.js b/cms/static/js/programs/models/course_model.js new file mode 100644 index 00000000000..b72a00207a5 --- /dev/null +++ b/cms/static/js/programs/models/course_model.js @@ -0,0 +1,38 @@ +define([ + 'backbone', + 'jquery', + 'js/programs/utils/api_config', + 'js/programs/models/auto_auth_model', + 'jquery.cookie', + 'gettext' + ], + function( Backbone, $, apiConfig, AutoAuthModel ) { + 'use strict'; + + return AutoAuthModel.extend({ + + validation: { + key: { + required: true, + maxLength: 64 + }, + display_name: { + required: true, + maxLength: 128 + } + }, + + labels: { + key: gettext('Course Code'), + display_name: gettext('Course Title') + }, + + defaults: { + display_name: false, + key: false, + organization: [], + run_modes: [] + } + }); + } +); diff --git a/cms/static/js/programs/models/course_run_model.js b/cms/static/js/programs/models/course_run_model.js new file mode 100644 index 00000000000..9e41a7e52ef --- /dev/null +++ b/cms/static/js/programs/models/course_run_model.js @@ -0,0 +1,17 @@ +define([ + 'backbone' + ], + function( Backbone ) { + 'use strict'; + + return Backbone.Model.extend({ + defaults: { + course_key: '', + mode_slug: 'verified', + sku: '', + start_date: '', + run_key: '' + } + }); + } +); diff --git a/cms/static/js/programs/models/organizations_model.js b/cms/static/js/programs/models/organizations_model.js new file mode 100644 index 00000000000..19fd43f0821 --- /dev/null +++ b/cms/static/js/programs/models/organizations_model.js @@ -0,0 +1,16 @@ +define([ + 'js/programs/utils/api_config', + 'js/programs/models/auto_auth_model' + ], + function( apiConfig, AutoAuthModel ) { + 'use strict'; + + return AutoAuthModel.extend({ + + url: function() { + return apiConfig.get('programsApiUrl') + 'organizations/?page_size=1000'; + } + + }); + } +); diff --git a/cms/static/js/programs/models/program_model.js b/cms/static/js/programs/models/program_model.js new file mode 100644 index 00000000000..bea3f722243 --- /dev/null +++ b/cms/static/js/programs/models/program_model.js @@ -0,0 +1,106 @@ +define([ + 'backbone', + 'jquery', + 'js/programs/utils/api_config', + 'js/programs/models/auto_auth_model', + 'jquery.cookie' + ], + function( Backbone, $, apiConfig, AutoAuthModel ) { + 'use strict'; + + return AutoAuthModel.extend({ + + // Backbone.Validation rules. + // See: http://thedersen.com/projects/backbone-validation/#configure-validation-rules-on-the-model. + validation: { + name: { + required: true, + maxLength: 255 + }, + subtitle: { + // The underlying Django model does not require a subtitle. + maxLength: 255 + }, + category: { + required: true, + // XSeries is currently the only valid Program type. + oneOf: ['xseries'] + }, + organizations: 'validateOrganizations', + marketing_slug: { + maxLength: 255 + } + }, + + initialize: function() { + this.url = apiConfig.get('programsApiUrl') + 'programs/' + this.id + '/'; + }, + + validateOrganizations: function( orgArray ) { + /** + * The array passed to this method contains a single object representing + * the selected organization; the object contains the organization's key. + * In the future, multiple organizations might be associated with a program. + */ + var i, + len = orgArray ? orgArray.length : 0; + + for ( i = 0; i < len; i++ ) { + if ( orgArray[i].key === 'false' ) { + return gettext('Please select a valid organization.'); + } + } + }, + + getConfig: function( options ) { + var patch = options && options.patch, + params = patch ? this.get('id') + '/' : '', + config = _.extend({ validate: true, parse: true }, { + type: patch ? 'PATCH' : 'POST', + url: apiConfig.get('programsApiUrl') + 'programs/' + params, + contentType: patch ? 'application/merge-patch+json' : 'application/json', + context: this, + // NB: setting context fails in tests + success: _.bind( this.saveSuccess, this ), + error: _.bind( this.saveError, this ) + }); + + if ( patch ) { + config.data = JSON.stringify( options.update ) || this.attributes; + } + + return config; + }, + + patch: function( data ) { + this.save({ + patch: true, + update: data + }); + }, + + save: function( options ) { + var method, + patch = options && options.patch ? true : false, + config = this.getConfig( options ); + + /** + * Simplified version of code from the default Backbone save function + * http://backbonejs.org/docs/backbone.html#section-87 + */ + method = this.isNew() ? 'create' : ( patch ? 'patch' : 'update' ); + + this.sync( method, this, config ); + }, + + saveError: function( jqXHR ) { + this.trigger( 'error', jqXHR ); + }, + + saveSuccess: function( data ) { + this.set({ id: data.id }); + this.trigger( 'sync', this ); + } + }); + } +); diff --git a/cms/static/js/programs/program_admin_app.js b/cms/static/js/programs/program_admin_app.js new file mode 100644 index 00000000000..1e5d293bf3b --- /dev/null +++ b/cms/static/js/programs/program_admin_app.js @@ -0,0 +1,10 @@ +(function() { + 'use strict'; + require([ + 'js/programs/views/program_admin_app_view' + ], + function( ProgramAdminAppView ) { + return new ProgramAdminAppView(); + } + ); +})(); diff --git a/cms/static/js/programs/router.js b/cms/static/js/programs/router.js new file mode 100644 index 00000000000..a5ebcb1d870 --- /dev/null +++ b/cms/static/js/programs/router.js @@ -0,0 +1,65 @@ +define([ + 'backbone', + 'js/programs/views/program_creator_view', + 'js/programs/views/program_details_view', + 'js/programs/models/program_model' + ], + function( Backbone, ProgramCreatorView, ProgramDetailsView, ProgramModel ) { + 'use strict'; + + return Backbone.Router.extend({ + root: '/program/', + + routes: { + 'new': 'programCreator', + ':id': 'programDetails' + }, + + initialize: function( options ) { + this.homeUrl = options.homeUrl; + }, + + goHome: function() { + window.location.href = this.homeUrl; + }, + + loadProgramDetails: function() { + this.programDetailsView = new ProgramDetailsView({ + model: this.programModel + }); + }, + + programCreator: function() { + if ( this.programCreatorView ) { + this.programCreatorView.destroy(); + } + + this.programCreatorView = new ProgramCreatorView({ + router: this + }); + }, + + programDetails: function( id ) { + this.programModel = new ProgramModel({ + id: id + }); + + this.programModel.on( 'sync', this.loadProgramDetails, this ); + this.programModel.fetch(); + }, + + /** + * Starts the router. + */ + start: function () { + if ( !Backbone.history.started ) { + Backbone.history.start({ + pushState: true, + root: this.root + }); + } + return this; + } + }); + } +); diff --git a/cms/static/js/programs/shims/gettext.js b/cms/static/js/programs/shims/gettext.js new file mode 100644 index 00000000000..45d4fa74500 --- /dev/null +++ b/cms/static/js/programs/shims/gettext.js @@ -0,0 +1,22 @@ +/** + * the Programs application loads gettext identity library via django, thus + * components reference gettext globally so a shim is added here to reflect + * the text so tests can be run if modules reference gettext + */ +(function() { + 'use strict'; + + if ( !window.gettext ) { + window.gettext = function (text) { + return text; + }; + } + + if ( !window.interpolate ) { + window.interpolate = function (text) { + return text; + }; + } + + return window; +})(); diff --git a/cms/static/js/programs/utils/api_config.js b/cms/static/js/programs/utils/api_config.js new file mode 100644 index 00000000000..5924363b27d --- /dev/null +++ b/cms/static/js/programs/utils/api_config.js @@ -0,0 +1,21 @@ +define([ + 'js/programs/models/api_config_model' + ], + function( ApiConfigModel ) { + 'use strict'; + + /** + * This js module implements the Singleton pattern for an instance + * of the ApiConfigModel Backbone model. It returns the same shared + * instance of that model anywhere it is required. + */ + var instance; + + if (instance === undefined) { + instance = new ApiConfigModel(); + } + + return instance; + + } +); diff --git a/cms/static/js/programs/utils/auth_utils.js b/cms/static/js/programs/utils/auth_utils.js new file mode 100644 index 00000000000..30bd541edf0 --- /dev/null +++ b/cms/static/js/programs/utils/auth_utils.js @@ -0,0 +1,89 @@ +define([ + 'jquery', + 'underscore', + 'js/programs/utils/api_config' + ], + function( $, _, apiConfig ) { + 'use strict'; + + var auth = { + autoSync: { + /** + * Override Backbone.sync to seamlessly attempt (re-)authentication when necessary. + * + * If a 401 error response is encountered while making a request to the Programs, + * API, this wrapper will attempt to request an id token from a custom endpoint + * via AJAX. Then the original request will be retried once more. + * + * Any other response than 401 on the original API request, or any error occurring + * on the retried API request (including 401), will be handled by the base sync + * implementation. + * + */ + sync: function( method, model, options ) { + + var oldError = options.error; + + this._setHeaders( options ); + + options.notifyOnError = false; // suppress Studio error pop-up that will happen if we get a 401 + + options.error = function(xhr, textStatus, errorThrown) { + if (xhr && xhr.status === 401) { + // attempt auth and retry + this._updateToken(function() { + // restore the original error handler + options.error = oldError; + options.notifyOnError = true; // if it fails again, let Studio notify. + delete options.xhr; // remove the failed (401) xhr from the last try. + + // update authorization header + this._setHeaders( options ); + + Backbone.sync.call(this, method, model, options); + }.bind(this)); + } else if (oldError) { + // fall back to the original error handler + oldError.call(this, xhr, textStatus, errorThrown); + } + }.bind(this); + return Backbone.sync.call(this, method, model, options); + }, + + /** + * Fix up headers on an imminent AJAX sync, ensuring that the JWT token is enclosed + * and that credentials are included when the request is being made cross-domain. + */ + _setHeaders: function( ajaxOptions ) { + ajaxOptions.headers = _.extend ( ajaxOptions.headers || {}, { + Authorization: 'JWT ' + apiConfig.get( 'idToken' ) + }); + ajaxOptions.xhrFields = _.extend( ajaxOptions.xhrFields || {}, { + withCredentials: true + }); + }, + + /** + * Fetch a new id token from the configured endpoint, update the api config, + * and invoke the specified callback. + */ + _updateToken: function( success ) { + + $.ajax({ + url: apiConfig.get('authUrl'), + xhrFields: { + // See: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials + withCredentials: true + }, + crossDomain: true + }).done(function ( data ) { + // save the newly-retrieved id token + apiConfig.set( 'idToken', data.id_token ); + }).done( success ); + } + } + }; + + return auth; + } +); diff --git a/cms/static/js/programs/utils/constants.js b/cms/static/js/programs/utils/constants.js new file mode 100644 index 00000000000..e60dea9a0b1 --- /dev/null +++ b/cms/static/js/programs/utils/constants.js @@ -0,0 +1,16 @@ +/** + * Reusable constants + */ +define([], function() { + 'use strict'; + + return { + keyCodes: { + tab: 9, + enter: 13, + esc: 27, + up: 38, + down: 40 + } + }; +}); diff --git a/cms/static/js/programs/utils/validation_config.js b/cms/static/js/programs/utils/validation_config.js new file mode 100644 index 00000000000..a0b18bd1139 --- /dev/null +++ b/cms/static/js/programs/utils/validation_config.js @@ -0,0 +1,70 @@ +define([ + 'backbone', + 'backbone.validation', + 'underscore', + 'gettext' + ], + function( Backbone, BackboneValidation, _ ) { + 'use strict'; + + var errorClass = 'has-error', + messageEl = '.field-message', + messageContent = '.field-message-content'; + + // These are the same messages provided by Backbone.Validation, + // marked for translation. + // See: http://thedersen.com/projects/backbone-validation/#overriding-the-default-error-messages. + _.extend( Backbone.Validation.messages, { + required: gettext( '{0} is required' ), + acceptance: gettext( '{0} must be accepted' ), + min: gettext( '{0} must be greater than or equal to {1}' ), + max: gettext( '{0} must be less than or equal to {1}' ), + range: gettext( '{0} must be between {1} and {2}' ), + length: gettext( '{0} must be {1} characters' ), + minLength: gettext( '{0} must be at least {1} characters' ), + maxLength: gettext( '{0} must be at most {1} characters' ), + rangeLength: gettext( '{0} must be between {1} and {2} characters' ), + oneOf: gettext( '{0} must be one of: gettext( {1}' ), + equalTo: gettext( '{0} must be the same as {1}' ), + digits: gettext( '{0} must only contain digits' ), + number: gettext( '{0} must be a number' ), + email: gettext( '{0} must be a valid email' ), + url: gettext( '{0} must be a valid url' ), + inlinePattern: gettext( '{0} is invalid' ) + }); + + _.extend( Backbone.Validation.callbacks, { + // Gets called when a previously invalid field in the + // view becomes valid. Removes any error message. + valid: function( view, attr, selector ) { + var $input = view.$( '[' + selector + '~="' + attr + '"]' ), + $message = $input.siblings( messageEl ); + + $input.removeClass( errorClass ) + .removeAttr( 'data-error' ); + + $message.removeClass( errorClass ) + .find( messageContent ) + .text( '' ); + }, + + // Gets called when a field in the view becomes invalid. + // Adds a error message. + invalid: function( view, attr, error, selector ) { + var $input = view.$( '[' + selector + '~="' + attr + '"]' ), + $message = $input.siblings( messageEl ); + + $input.addClass( errorClass ) + .attr( 'data-error', error ); + + $message.addClass( errorClass ) + .find( messageContent ) + .text( $input.data('error') ); + } + }); + + Backbone.Validation.configure({ + labelFormatter: 'label' + }); + } +); diff --git a/cms/static/js/programs/views/confirm_modal_view.js b/cms/static/js/programs/views/confirm_modal_view.js new file mode 100644 index 00000000000..82db156b06c --- /dev/null +++ b/cms/static/js/programs/views/confirm_modal_view.js @@ -0,0 +1,59 @@ +define([ + 'backbone', + 'jquery', + 'underscore', + 'js/programs/utils/constants', + 'text!templates/programs/confirm_modal.underscore', + 'edx-ui-toolkit/js/utils/html-utils', + 'gettext' + ], + function( Backbone, $, _, constants, ModalTpl, HtmlUtils ) { + 'use strict'; + + return Backbone.View.extend({ + events: { + 'click .js-cancel': 'destroy', + 'click .js-confirm': 'confirm', + 'keydown': 'handleKeydown' + }, + + tpl: HtmlUtils.template( ModalTpl ), + + initialize: function( options ) { + this.$parentEl = $( options.parentEl ); + this.callback = options.callback; + this.content = options.content; + this.render(); + }, + + render: function() { + HtmlUtils.setHtml(this.$el, this.tpl( this.content )); + HtmlUtils.setHtml(this.$parentEl, HtmlUtils.HTML(this.$el)); + this.postRender(); + }, + + postRender: function() { + this.$el.find('.js-focus-first').focus(); + }, + + confirm: function() { + this.callback(); + this.destroy(); + }, + + destroy: function() { + this.undelegateEvents(); + this.remove(); + this.$parentEl.html(''); + }, + + handleKeydown: function( event ) { + var keyCode = event.keyCode; + + if ( keyCode === constants.keyCodes.esc ) { + this.destroy(); + } + } + }); + } +); diff --git a/cms/static/js/programs/views/course_details_view.js b/cms/static/js/programs/views/course_details_view.js new file mode 100644 index 00000000000..7025ec07ee0 --- /dev/null +++ b/cms/static/js/programs/views/course_details_view.js @@ -0,0 +1,204 @@ +define([ + 'backbone', + 'backbone.validation', + 'jquery', + 'underscore', + 'js/programs/models/course_model', + 'js/programs/models/course_run_model', + 'js/programs/models/program_model', + 'js/programs/views/course_run_view', + 'text!templates/programs/course_details.underscore', + 'edx-ui-toolkit/js/utils/html-utils', + 'gettext', + 'js/programs/utils/validation_config' + ], + function( Backbone, BackboneValidation, $, _, CourseModel, CourseRunModel, + ProgramModel, CourseRunView, ListTpl, HtmlUtils ) { + 'use strict'; + + return Backbone.View.extend({ + parentEl: '.js-course-list', + + className: 'course-details', + + events: { + 'click .js-remove-course': 'destroy', + 'click .js-select-course': 'setCourse', + 'click .js-add-course-run': 'addCourseRun' + }, + + tpl: HtmlUtils.template( ListTpl ), + + initialize: function( options ) { + this.model = new CourseModel(); + Backbone.Validation.bind( this ); + this.$parentEl = $( this.parentEl ); + + // For managing subViews + this.courseRunViews = []; + this.courseRuns = options.courseRuns; + this.programModel = options.programModel; + + if ( options.courseData ) { + this.model.set(options.courseData); + } else { + this.model.set({run_modes: []}); + } + + // Need a unique value for field ids so using model cid + this.model.set({cid: this.model.cid}); + this.model.on('change:run_modes', this.updateRuns, this); + this.render(); + }, + + render: function() { + HtmlUtils.setHtml(this.$el, this.tpl(this.formatData())); + this.$parentEl.append( this.$el ); + this.postRender(); + }, + + postRender: function() { + var runs = this.model.get('run_modes'); + if ( runs && runs.length > 0 ) { + this.addCourseRuns(); + } + }, + + addCourseRun: function(event) { + var $runsContainer = this.$el.find('.js-course-runs'), + runModel = new CourseRunModel(), + runView; + + event.preventDefault(); + + runModel.set({course_key: undefined}); + + runView = new CourseRunView({ + model: runModel, + courseModel: this.model, + courseRuns: this.courseRuns, + programStatus: this.programModel.get('status'), + $parentEl: $runsContainer + }); + + this.courseRunViews.push( runView ); + }, + + addCourseRuns: function() { + // Create run views + var runs = this.model.get('run_modes'), + $runsContainer = this.$el.find('.js-course-runs'); + + _.each( runs, function( run ) { + var runModel = new CourseRunModel(), + runView; + + runModel.set(run); + + runView = new CourseRunView({ + model: runModel, + courseModel: this.model, + courseRuns: this.courseRuns, + programStatus: this.programModel.get('status'), + $parentEl: $runsContainer + }); + + this.courseRunViews.push( runView ); + + return runView; + }.bind(this) ); + }, + + addCourseToProgram: function() { + var courseCodes = this.programModel.get('course_codes'), + courseData = this.model.toJSON(); + + if ( this.programModel.isValid( true ) ) { + // We don't want to save the cid so omit it + courseCodes.push( _.omit(courseData, 'cid') ); + this.programModel.patch({ course_codes: courseCodes }); + } + }, + // Delete this view + destroy: function() { + Backbone.Validation.unbind(this); + this.destroyChildren(); + this.undelegateEvents(); + this.removeCourseFromProgram(); + this.remove(); + }, + + destroyChildren: function() { + var runs = this.courseRunViews; + + _.each( runs, function( run ) { + run.removeRun(); + }); + }, + + // Format data to be passed to the template + formatData: function() { + var data = $.extend( {}, + { courseRuns: this.courseRuns.models }, + _.omit( this.programModel.toJSON(), 'run_modes'), + this.model.toJSON() + ); + + return data; + }, + + removeCourseFromProgram: function() { + var courseCodes = this.programModel.get('course_codes'), + key = this.model.get('key'), + name = this.model.get('display_name'), + update = []; + + update = _.reject( courseCodes, function(course) { + return course.key === key && course.display_name === name; + }); + + this.programModel.patch({ course_codes: update }); + }, + + setCourse: function( event ) { + var $form = this.$('.js-course-form'), + title = $form.find('.display-name').val(), + key = $form.find('.course-key').val(); + + event.preventDefault(); + + this.model.set({ + display_name: title, + key: key, + organization: this.programModel.get('organizations')[0] + }); + + if ( this.model.isValid(true) ) { + this.addCourseToProgram(); + this.updateDOM(); + this.addCourseRuns(); + } + }, + + updateDOM: function() { + HtmlUtils.setHtml(this.$el, this.tpl( this.formatData() ) ); + }, + + updateRuns: function() { + var courseCodes = this.programModel.get('course_codes'), + key = this.model.get('key'), + name = this.model.get('display_name'), + index; + + if ( this.programModel.isValid( true ) ) { + index = _.findIndex( courseCodes, function(course) { + return course.key === key && course.display_name === name; + }); + courseCodes[index] = this.model.toJSON(); + + this.programModel.patch({ course_codes: courseCodes }); + } + } + }); + } +); diff --git a/cms/static/js/programs/views/course_run_view.js b/cms/static/js/programs/views/course_run_view.js new file mode 100644 index 00000000000..7284493b668 --- /dev/null +++ b/cms/static/js/programs/views/course_run_view.js @@ -0,0 +1,113 @@ +define([ + 'backbone', + 'jquery', + 'underscore', + 'text!templates/programs/course_run.underscore', + 'edx-ui-toolkit/js/utils/html-utils' + ], + function ( Backbone, $, _, CourseRunTpl, HtmlUtils ) { + 'use strict'; + + return Backbone.View.extend({ + events: { + 'change .js-course-run-select': 'selectRun', + 'click .js-remove-run': 'removeRun' + }, + + tpl: HtmlUtils.template( CourseRunTpl ), + + initialize: function( options ) { + /** + * Need the run model for the template, and the courseModel + * to keep parent view up to date with run changes + */ + this.courseModel = options.courseModel; + this.courseRuns = options.courseRuns; + this.programStatus = options.programStatus; + + this.model.on('change', this.render, this); + this.courseRuns.on('update', this.updateDropdown, this); + + this.$parentEl = options.$parentEl; + this.render(); + }, + + render: function() { + var data = this.model.attributes; + + data.programStatus = this.programStatus; + + if ( !!this.courseRuns ) { + data.courseRuns = this.courseRuns.toJSON(); + } + + HtmlUtils.setHtml(this.$el, this.tpl( data ) ); + this.$parentEl.append( this.$el ); + }, + + // Delete this view + destroy: function() { + this.undelegateEvents(); + this.remove(); + }, + + // Data returned from courseList API is not the correct format + formatData: function( data ) { + return { + course_key: data.id, + mode_slug: 'verified', + start_date: data.start, + sku: '' + }; + }, + + removeRun: function() { + // Update run_modes array on programModel + var startDate = this.model.get('start_date'), + courseKey = this.model.get('course_key'), + /** + * NB: cloning the array so the model will fire a change event when + * the updated version is saved back to the model + */ + runs = _.clone(this.courseModel.get('run_modes')), + updatedRuns = []; + + updatedRuns = _.reject( runs, function( obj ) { + return obj.start_date === startDate && + obj.course_key === courseKey; + }); + + this.courseModel.set({ + run_modes: updatedRuns + }); + + this.courseRuns.addRun(courseKey); + + this.destroy(); + }, + + selectRun: function(event) { + var id = $(event.currentTarget).val(), + runObj = _.findWhere(this.courseRuns.allRuns, {id: id}), + /** + * NB: cloning the array so the model will fire a change event when + * the updated version is saved back to the model + */ + runs = _.clone(this.courseModel.get('run_modes')), + data = this.formatData(runObj); + + this.model.set( data ); + runs.push(data); + this.courseModel.set({run_modes: runs}); + this.courseRuns.removeRun(id); + }, + + // If a run has not been selected update the dropdown options + updateDropdown: function() { + if ( !this.model.get('course_key') ) { + this.render(); + } + } + }); + } +); diff --git a/cms/static/js/programs/views/program_admin_app_view.js b/cms/static/js/programs/views/program_admin_app_view.js new file mode 100644 index 00000000000..4f0a5e184b0 --- /dev/null +++ b/cms/static/js/programs/views/program_admin_app_view.js @@ -0,0 +1,68 @@ +(function() { + 'use strict'; + + define([ + 'backbone', + 'js/programs/router', + 'js/programs/utils/api_config' + ], + function( Backbone, ProgramRouter, apiConfig ) { + return Backbone.View.extend({ + el: '.js-program-admin', + + events: { + 'click .js-app-click': 'navigate' + }, + + initialize: function() { + apiConfig.set({ + lmsBaseUrl: this.$el.data('lms-base-url'), + programsApiUrl: this.$el.data('programs-api-url'), + authUrl: this.$el.data('auth-url'), + username: this.$el.data('username') + }); + + this.app = new ProgramRouter({ + homeUrl: this.$el.data('home-url') + }); + this.app.start(); + }, + + /** + * Navigate to a new page within the app. + * + * Attempts to open the link in a new tab/window behave as the user expects, however the app + * and data will be reloaded in the new tab/window. + * + * @param {Event} event - Event being handled. + * @returns {boolean} - Indicates if event handling succeeded (always true). + */ + navigate: function (event) { + var url = $(event.target).attr('href').replace( this.app.root, '' ); + + /** + * Handle the cases where the user wants to open the link in a new tab/window. + * event.which === 2 checks for the middle mouse button (https://api.jquery.com/event.which/) + */ + if ( event.ctrlKey || event.shiftKey || event.metaKey || event.which === 2 ) { + return true; + } + + // We'll take it from here... + event.preventDefault(); + + // Process the navigation in the app/router. + if ( url === Backbone.history.getFragment() && url === '' ) { + /** + * Note: We must call the index directly since Backbone + * does not support routing to the same route. + */ + this.app.index(); + } else { + this.app.navigate( url, { trigger: true } ); + } + } + }); + } + ); +})(); diff --git a/cms/static/js/programs/views/program_creator_view.js b/cms/static/js/programs/views/program_creator_view.js new file mode 100644 index 00000000000..6f07486cdf9 --- /dev/null +++ b/cms/static/js/programs/views/program_creator_view.js @@ -0,0 +1,112 @@ +define([ + 'backbone', + 'backbone.validation', + 'jquery', + 'underscore', + 'js/programs/models/organizations_model', + 'js/programs/models/program_model', + 'text!templates/programs/program_creator_form.underscore', + 'edx-ui-toolkit/js/utils/html-utils', + 'gettext', + 'js/programs/utils/validation_config' + ], + function ( Backbone, BackboneValidation, $, _, OrganizationsModel, ProgramModel, ListTpl, HtmlUtils ) { + 'use strict'; + + return Backbone.View.extend({ + parentEl: '.js-program-admin', + + events: { + 'click .js-create-program': 'createProgram', + 'click .js-abort-view': 'abort' + }, + + tpl: HtmlUtils.template( ListTpl ), + + initialize: function( options ) { + this.$parentEl = $( this.parentEl ); + + this.model = new ProgramModel(); + this.model.on( 'sync', this.saveSuccess, this ); + this.model.on( 'error', this.saveError, this ); + + // Hook up validation. + // See: http://thedersen.com/projects/backbone-validation/#validation-binding. + Backbone.Validation.bind( this ); + + this.organizations = new OrganizationsModel(); + this.organizations.on( 'sync', this.render, this ); + this.organizations.fetch(); + + this.router = options.router; + }, + + render: function() { + HtmlUtils.setHtml( + this.$el, + this.tpl( { + orgs: this.organizations.get('results') + }) + ); + + HtmlUtils.setHtml(this.$parentEl, HtmlUtils.HTML( this.$el )); + }, + + abort: function( event ) { + event.preventDefault(); + this.router.goHome(); + }, + + createProgram: function( event ) { + var data = this.getData(); + + event.preventDefault(); + this.model.set( data ); + + // Check if the model is valid before saving. Invalid attributes are looked + // up by name. The corresponding elements receieve an `invalid` class and a + // `data-error` attribute. Both are removed when formerly invalid attributes + // become valid. + // See: http://thedersen.com/projects/backbone-validation/#isvalid. + if ( this.model.isValid(true) ) { + this.model.save(); + } + }, + + destroy: function() { + // Unhook validation. + // See: http://thedersen.com/projects/backbone-validation/#unbinding. + Backbone.Validation.unbind(this); + + this.undelegateEvents(); + this.remove(); + }, + + getData: function() { + return { + name: this.$el.find( '.program-name' ).val(), + subtitle: this.$el.find( '.program-subtitle' ).val(), + category: this.$el.find( '.program-type' ).val(), + marketing_slug: this.$el.find( '.program-marketing-slug' ).val(), + organizations: [{ + key: this.$el.find( '.program-org' ).val() + }] + }; + }, + + goToView: function( uri ) { + Backbone.history.navigate( uri, { trigger: true } ); + this.destroy(); + }, + + // TODO: add user messaging to show errors + saveError: function( jqXHR ) { + console.log( 'saveError: ', jqXHR ); + }, + + saveSuccess: function() { + this.goToView( String( this.model.get( 'id' ) ) ); + } + }); + } +); diff --git a/cms/static/js/programs/views/program_details_view.js b/cms/static/js/programs/views/program_details_view.js new file mode 100644 index 00000000000..2fb9fafc6a1 --- /dev/null +++ b/cms/static/js/programs/views/program_details_view.js @@ -0,0 +1,207 @@ +define([ + 'backbone', + 'backbone.validation', + 'jquery', + 'underscore', + 'js/programs/collections/course_runs_collection', + 'js/programs/models/program_model', + 'js/programs/views/confirm_modal_view', + 'js/programs/views/course_details_view', + 'text!templates/programs/program_details.underscore', + 'edx-ui-toolkit/js/utils/html-utils', + 'gettext', + 'js/programs/utils/validation_config' + ], + function( Backbone, BackboneValidation, $, _, CourseRunsCollection, + ProgramModel, ModalView, CourseView, ListTpl, + HtmlUtils ) { + 'use strict'; + + return Backbone.View.extend({ + el: '.js-program-admin', + + events: { + 'blur .js-inline-edit input': 'checkEdit', + 'click .js-add-course': 'addCourse', + 'click .js-enable-edit': 'editField', + 'click .js-publish-program': 'confirmPublish' + }, + + tpl: HtmlUtils.template( ListTpl ), + + initialize: function() { + Backbone.Validation.bind( this ); + + this.courseRuns = new CourseRunsCollection([], { + organization: this.model.get('organizations')[0] + }); + this.courseRuns.fetch(); + this.courseRuns.on('sync', this.setAvailableCourseRuns, this); + this.render(); + }, + + render: function() { + HtmlUtils.setHtml(this.$el, this.tpl( this.model.toJSON() ) ); + this.postRender(); + }, + + postRender: function() { + var courses = this.model.get( 'course_codes' ); + + _.each( courses, function( course ) { + var title = course.key + 'Course'; + + this[ title ] = new CourseView({ + courseRuns: this.courseRuns, + programModel: this.model, + courseData: course + }); + }.bind(this) ); + + // Stop listening to the model sync set when publishing + this.model.off( 'sync' ); + }, + + addCourse: function() { + return new CourseView({ + courseRuns: this.courseRuns, + programModel: this.model + }); + }, + + checkEdit: function( event ) { + var $input = $(event.target), + $span = $input.prevAll('.js-model-value'), + $btn = $input.next('.js-enable-edit'), + value = $input.val(), + key = $input.data('field'), + data = {}; + + data[key] = value; + + $input.addClass('is-hidden'); + $btn.removeClass('is-hidden'); + $span.removeClass('is-hidden'); + + if ( this.model.get( key ) !== value ) { + this.model.set( data ); + + if ( this.model.isValid( true ) ) { + this.model.patch( data ); + $span.text( value ); + } + } + }, + + /** + * Loads modal that user clicks a confirmation button + * in to publish the course (or they can cancel out of it) + */ + confirmPublish: function( event ) { + event.preventDefault(); + + /** + * Update validation to make marketing slug required + * Note that because this validation is not required for + * the program creation form and is only happening here + * it makes sense to have the validation at the view level + */ + if ( this.model.isValid( true ) && this.validateMarketingSlug() ) { + this.modalView = new ModalView({ + model: this.model, + callback: _.bind( this.publishProgram, this ), + content: this.getModalContent(), + parentEl: '.js-publish-modal', + parentView: this + }); + } + }, + + editField: function( event ) { + /** + * Making the assumption that users can only see + * programs that they have permission to edit + */ + var $btn = $( event.currentTarget ), + $el = $btn.prev( 'input' ); + + event.preventDefault(); + + $el.prevAll( '.js-model-value' ).addClass( 'is-hidden' ); + $el.removeClass( 'is-hidden' ) + .addClass( 'edit' ) + .focus(); + $btn.addClass( 'is-hidden' ); + }, + + getModalContent: function() { + /* jshint maxlen: 300 */ + return { + name: gettext('confirm'), + title: gettext('Publish this program?'), + body: gettext( + 'After you publish this program, you cannot add or remove course codes or remove course runs.' + ), + cta: { + cancel: gettext('Cancel'), + confirm: gettext('Publish') + } + }; + }, + + publishProgram: function() { + var data = { + status: 'active' + }; + + this.model.set( data, { silent: true } ); + this.model.on( 'sync', this.render, this ); + this.model.patch( data ); + }, + + setAvailableCourseRuns: function() { + var allRuns = this.courseRuns.toJSON(), + courses = this.model.get('course_codes'), + selectedRuns, + availableRuns = allRuns; + + if (courses.length) { + selectedRuns = _.pluck( courses, 'run_modes' ); + selectedRuns = _.flatten( selectedRuns ); + } + + availableRuns = _.reject(allRuns, function(run) { + var selectedCourseRun = _.findWhere( selectedRuns, { + course_key: run.id, + start_date: run.start + }); + + return !_.isUndefined(selectedCourseRun); + }); + + this.courseRuns.set(availableRuns); + }, + + validateMarketingSlug: function() { + var isValid = false, + $input = {}, + $message = {}; + + if ( this.model.get( 'marketing_slug' ).length > 0 ) { + isValid = true; + } else { + $input = this.$el.find( '#program-marketing-slug' ); + $message = $input.siblings( '.field-message' ); + + // Update DOM + $input.addClass( 'has-error' ); + $message.addClass( 'has-error' ); + $message.find( '.field-message-content' ) + .text( gettext( 'Marketing Slug is required.') ); + } + + return isValid; + } + }); + } +); diff --git a/cms/static/js/spec/models/auto_auth_model_spec.js b/cms/static/js/spec/models/auto_auth_model_spec.js new file mode 100644 index 00000000000..bdec67c1be5 --- /dev/null +++ b/cms/static/js/spec/models/auto_auth_model_spec.js @@ -0,0 +1,116 @@ +define([ + 'underscore', + 'backbone', + 'jquery', + 'js/programs/utils/api_config', + 'js/programs/models/auto_auth_model' + ], + function( _, Backbone, $, apiConfig, AutoAuthModel ) { + 'use strict'; + + describe('AutoAuthModel', function () { + + var model, + testErrorCallback, + fakeAjaxDeferred, + spyOnBackboneSync, + callSync, + checkAuthAttempted, + dummyModel = {'dummy': 'model'}, + authUrl = apiConfig.get( 'authUrl' ); + + beforeEach( function() { + + // instance under test + model = new AutoAuthModel(); + + // stand-in for the error callback a caller might pass with options to Backbone.Model.sync + testErrorCallback = jasmine.createSpy(); + + fakeAjaxDeferred = $.Deferred(); + spyOn( $, 'ajax' ).and.returnValue( fakeAjaxDeferred ); + return fakeAjaxDeferred; + + }); + + spyOnBackboneSync = function( status ) { + // set up Backbone.sync to invoke its error callback with the desired HTTP status + spyOn( Backbone, 'sync' ).and.callFake( function(method, model, options) { + var fakeXhr = options.xhr = { status: status }; + options.error(fakeXhr, 0, ''); + }); + }; + + callSync = function(options) { + var params, + syncOptions = _.extend( { error: testErrorCallback }, options || {} ); + + model.sync('GET', dummyModel, syncOptions); + + // make sure Backbone.sync was called with custom error handling + expect( Backbone.sync.calls.count() ).toEqual(1); + params = _.object( ['method', 'model', 'options'], Backbone.sync.calls.mostRecent().args ); + expect( params.method ).toEqual( 'GET' ); + expect( params.model ).toEqual( dummyModel ); + expect( params.options.error ).not.toEqual( testErrorCallback ); + return params; + }; + + checkAuthAttempted = function(isExpected) { + if (isExpected) { + expect( $.ajax ).toHaveBeenCalled(); + expect( $.ajax.calls.mostRecent().args[0].url ).toEqual( authUrl ); + } else { + expect( $.ajax ).not.toHaveBeenCalled(); + } + }; + + it( 'should exist', function () { + expect( model ).toBeDefined(); + }); + + it( 'should intercept 401 errors and attempt auth', function() { + + var callParams; + + spyOnBackboneSync(401); + + callSync(); + + // make sure the auth attempt was initiated + checkAuthAttempted(true); + + // fire the success handler for the fake ajax call, with id token response data + fakeAjaxDeferred.resolve( {id_token: 'test-id-token'} ); + + // make sure the original request was retried with token, and without custom error handling + expect( Backbone.sync.calls.count() ).toEqual(2); + callParams = _.object( ['method', 'model', 'options'], Backbone.sync.calls.mostRecent().args ); + expect( callParams.method ).toEqual( 'GET' ); + expect( callParams.model ).toEqual( dummyModel ); + expect( callParams.options.error ).toEqual( testErrorCallback ); + expect( callParams.options.headers.Authorization ).toEqual( 'JWT test-id-token' ); + + }); + + it( 'should not intercept non-401 errors', function() { + + spyOnBackboneSync(403); + + // invoke AutoAuthModel.sync + callSync(); + + // make sure NO auth attempt was initiated + checkAuthAttempted(false); + + // make sure the original request was not retried + expect( Backbone.sync.calls.count() ).toEqual(1); + + // make sure the default error handling was invoked + expect( testErrorCallback ).toHaveBeenCalled(); + + }); + + }); + } +); diff --git a/cms/static/js/spec/views/programs/program_creator_spec.js b/cms/static/js/spec/views/programs/program_creator_spec.js new file mode 100644 index 00000000000..21a59d6e6a0 --- /dev/null +++ b/cms/static/js/spec/views/programs/program_creator_spec.js @@ -0,0 +1,217 @@ +define([ + 'backbone', + 'jquery', + 'js/programs/views/program_creator_view' + ], + function( Backbone, $, ProgramCreatorView ) { + 'use strict'; + + describe('ProgramCreatorView', function () { + var view = {}, + Router = Backbone.Router.extend({ + initialize: function( options ) { + this.homeUrl = options.homeUrl; + }, + goHome: function() { + window.location.href = this.homeUrl; + } + }), + organizations = { + count: 1, + previous: null, + 'num_pages': 1, + results:[{ + 'display_name': 'test-org-display_name', + 'key': 'test-org-key' + }], + next: null + }, + sampleInput = { + organizations: 'test-org-key', + name: 'Test Course Name', + subtitle: 'Test Course Subtitle', + marketing_slug: 'test-management' + }, + completeForm = function( data ) { + view.$el.find('#program-name').val( data.name ); + view.$el.find('#program-subtitle').val( data.subtitle ); + view.$el.find('#program-org').val( data.organizations ); + + if ( data.category ) { + view.$el.find('#program-type').val( data.category ); + } + + if ( data.marketing_slug ) { + view.$el.find('#program-marketing-slug').val( data.marketing_slug ); + } + }, + verifyValidation = function ( data, invalidAttr ) { + var errorClass = 'has-error', + $invalidElement = view.$el.find( '[name="' + invalidAttr + '"]' ), + $errorMsg = $invalidElement.siblings('.field-message'), + inputErrorMsg = ''; + + completeForm( data ); + + view.$el.find('.js-create-program').click(); + inputErrorMsg = $invalidElement.data('error'); + + expect( view.model.save ).not.toHaveBeenCalled(); + expect( $invalidElement ).toHaveClass( errorClass ); + expect( $errorMsg ).toHaveClass( errorClass ); + expect( inputErrorMsg ).toBeDefined(); + expect( $errorMsg.find('.field-message-content').html() ).toEqual( inputErrorMsg ); + }; + + beforeEach( function() { + // Set the DOM + setFixtures( '<div class="js-program-admin"></div>' ); + + jasmine.clock().install(); + + spyOn( ProgramCreatorView.prototype, 'saveSuccess' ).and.callThrough(); + spyOn( ProgramCreatorView.prototype, 'goToView' ).and.callThrough(); + spyOn( ProgramCreatorView.prototype, 'saveError' ).and.callThrough(); + spyOn( Router.prototype, 'goHome' ); + + view = new ProgramCreatorView({ + router: new Router({ + homeUrl: '/author/home' + }) + }); + + view.organizations.set( organizations ); + view.render(); + }); + + afterEach( function() { + view.destroy(); + + jasmine.clock().uninstall(); + }); + + it( 'should exist', function () { + expect( view ).toBeDefined(); + }); + + it ( 'should get the form data', function() { + var formData = {}; + + completeForm( sampleInput ); + formData = view.getData(); + + expect( formData.name ).toEqual( sampleInput.name ); + expect( formData.subtitle ).toEqual( sampleInput.subtitle ); + expect( formData.organizations[0].key ).toEqual( sampleInput.organizations ); + }); + + it( 'should submit the form when the user clicks submit', function() { + var programId = 123; + + completeForm( sampleInput ); + + spyOn( $, 'ajax' ).and.callFake( function( event ) { + event.success({ id: programId }); + }); + + view.$el.find('.js-create-program').click(); + + expect( $.ajax ).toHaveBeenCalled(); + expect( view.saveSuccess ).toHaveBeenCalled(); + expect( view.goToView ).toHaveBeenCalledWith( String( programId ) ); + expect( view.saveError ).not.toHaveBeenCalled(); + }); + + it( 'should run the saveError when model save failures occur', function() { + spyOn( $, 'ajax' ).and.callFake( function( event ) { + event.error(); + }); + + // Fill out the form with valid data so that form model validation doesn't + // prevent the model from being saved. + completeForm( sampleInput ); + view.$el.find('.js-create-program').click(); + + expect( $.ajax ).toHaveBeenCalled(); + expect( view.saveSuccess ).not.toHaveBeenCalled(); + expect( view.saveError ).toHaveBeenCalled(); + }); + + it( 'should set the model when valid form data is submitted', function() { + completeForm( sampleInput ); + + spyOn( $, 'ajax' ).and.callFake( function( event ) { + event.success({ id: 10001110101 }); + }); + + view.$el.find('.js-create-program').click(); + + expect( view.model.get('name') ).toEqual( sampleInput.name ); + expect( view.model.get('subtitle') ).toEqual( sampleInput.subtitle ); + expect( view.model.get('organizations')[0].key ).toEqual( sampleInput.organizations ); + expect( view.model.get('marketing_slug') ).toEqual( sampleInput.marketing_slug ); + }); + + it( 'should not set the model when an invalid program name is submitted', function() { + var invalidInput = $.extend({}, sampleInput); + + spyOn( view.model, 'save' ); + + // No name provided. + invalidInput.name = ''; + verifyValidation( invalidInput, 'name' ); + + // Name is too long. + invalidInput.name = 'x'.repeat(256); + verifyValidation( invalidInput, 'name' ); + }); + + it( 'should not set the model when an invalid program subtitle is submitted', function() { + var invalidInput = $.extend({}, sampleInput); + + spyOn( view.model, 'save' ); + + // Subtitle is too long. + invalidInput.subtitle = 'x'.repeat(300); + verifyValidation( invalidInput, 'subtitle' ); + }); + + it( 'should not set the model when an invalid category is submitted', function() { + var invalidInput = $.extend({}, sampleInput); + + spyOn( view.model, 'save' ); + + // Category other than 'xseries' selected. + invalidInput.category = 'yseries'; + verifyValidation( invalidInput, 'category' ); + }); + + it( 'should not set the model when an invalid organization key is submitted', function() { + var invalidInput = $.extend({}, sampleInput); + + spyOn( view.model, 'save' ); + + // No organization selected. + invalidInput.organizations = 'false'; + verifyValidation( invalidInput, 'organizations' ); + }); + + it( 'should not set the model when an invalid marketing slug is submitted', function() { + var invalidInput = $.extend({}, sampleInput); + + spyOn( view.model, 'save' ); + + // Marketing slug is too long. + invalidInput.marketing_slug = 'x'.repeat(256); + verifyValidation( invalidInput, 'marketing_slug' ); + }); + + it( 'should abort the view when the cancel button is clicked', function() { + completeForm( sampleInput ); + expect( view.$parentEl.html().length ).toBeGreaterThan( 0 ); + view.$el.find('.js-abort-view').click(); + expect( view.router.goHome ).toHaveBeenCalled(); + }); + }); + } +); diff --git a/cms/static/js/spec/views/programs/program_details_spec.js b/cms/static/js/spec/views/programs/program_details_spec.js new file mode 100644 index 00000000000..b84b123dd82 --- /dev/null +++ b/cms/static/js/spec/views/programs/program_details_spec.js @@ -0,0 +1,533 @@ +define([ + 'jquery', + 'js/programs/collections/course_runs_collection', + 'js/programs/models/program_model', + 'js/programs/views/course_run_view', + 'js/programs/views/program_details_view', + 'js/programs/utils/constants' + ], + function( $, CourseRunsCollection, ProgramModel, CourseRunView, + ProgramDetailsView, constants ) { + 'use strict'; + + /* jshint maxlen: 300 */ + describe('ProgramDetailsView', function () { + var view = {}, + model = {}, + courseRunsList = [ + { + id: 'course-v1:edX+DemoX+Demo_Course', + name: 'edX Demonstration Course', + number: 'DemoX', + org: 'edX', + short_description: null, + effort: null, + media: { + course_image: { + uri: '/asset-v1:edX+DemoX+Demo_Course+type@asset+block@images_course_image.jpg' + }, + course_video: { + uri: null + } + }, + start: 'May 23, 2015', + start_type: 'timestamp', + start_display: 'Feb. 5, 2013', + end: null, + enrollment_start: null, + enrollment_end: null, + blocks_url: 'http://127.0.0.1:8000/api/courses/v1/blocks/?course_id=course-v1%3AedX%2BDemoX%2BDemo_Course' + }, + { + id: 'course-v1:edx+Krampus25+2015_12_05', + name: 'Krampusnacht', + number: 'Krampus25', + org: 'edx', + short_description: null, + effort: null, + media: { + course_image: { + uri: '/asset-v1:edx+Krampus25+2015_12_05+type@asset+block@images_course_image.jpg' + }, + course_video: { + uri: null + } + }, + start: '2030-01-01T00:00:00Z', + start_type: 'empty', + start_display: null, + end: null, + enrollment_start: null, + enrollment_end: null, + blocks_url: 'http://127.0.0.1:8000/api/courses/v1/blocks/?course_id=course-v1%3Aedx%2BKrampus25%2B2015_12_05' + }, + { + id: 'course-v1:edx+shiaLB101+2016_01', + name: 'Shia "The Beef"', + number: 'shiaLB101', + org: 'edx', + short_description: null, + effort: null, + media: { + course_image: { + uri: '/asset-v1:edx+shiaLB101+2016_01+type@asset+block@images_course_image.jpg' + }, + course_video: { + uri: null + } + }, + start: '2030-01-01T00:00:00Z', + start_type: 'empty', + start_display: null, + end: null, + enrollment_start: null, + enrollment_end: null, + blocks_url: 'http://127.0.0.1:8000/api/courses/v1/blocks/?course_id=course-v1%3Aedx%2BshiaLB101%2B2016_01' + } + ], + programData = { + category: 'xseries', + course_codes: [{ + display_name: 'test-course-display_name', + key: 'test-course-key', + organization: { + display_name: 'test-org-display_name', + key: 'test-org-key' + }, + run_modes: [ + { + course_key: 'course-v1:edX+DemoX+Demo_Course', + mode_slug: 'honor', + sku: null, + start: 'May 23, 2015' + }, { + course_key: 'course-v1:edX+DemoX+Demo_Course', + mode_slug: 'honor', + sku: null, + start: 'August 01, 2015' + }, { + course_key: 'course-v1:edX+DemoX+Demo_Course', + mode_slug: 'honor', + sku: null, + start: 'December 11, 2015' + } + ] + }], + created: '2015-10-20T18:11:46.854451Z', + id: 5, + marketing_slug: 'test-program-slug', + modified: '2015-10-20T18:11:46.854735Z', + name: 'test-program-5', + organizations: [{ + display_name: 'test-org-display_name', + key: 'test-org-key' + }], + status: 'unpublished', + subtitle: 'test-subtitle' + }, + testTimeoutInterval = 100, + errorClass = 'has-error', + addCourse, + completeCourseForm, + dropdownSelect, + editField, + keyPress, + openPublishModal, + resetCourseCodes, + testHidingButtonsAfterPublish, + testInvalidUpdate, + testUnchangedFieldBlur, + testUpdatedFieldBlur; + + addCourse = function() { + var $addCourseBtn = view.$el.find('.js-add-course').first(), + $form, + $submitBtn; + + expect( view.$el.find('.course-details').length ).toEqual( 1 ); + $addCourseBtn.click(); + $form = view.$('.js-course-form'); + $submitBtn = $form.find('.js-select-course'); + completeCourseForm(); + $submitBtn.click(); + expect( $form.find('.field-message.has-error').length ).toEqual( 0 ); + }; + + completeCourseForm = function() { + var $form = view.$('.js-course-form'); + + $form.find('.course-key').val('123'); + $form.find('.display-name').val('test course 1'); + }; + + dropdownSelect = function( $select, value ) { + $select.find('option:selected').prop('selected', false); + $select.val(value).prop('selected', true); + $select.trigger('change'); + }; + + editField = function( el, str ) { + var $input = view.$el.find( el ), + $btn = $input.next( '.js-enable-edit' ); + + expect( document.activeElement ).not.toEqual( $input[0] ); + expect( $input ).not.toHaveClass( 'edit' ); + expect( $input ).toHaveClass( 'is-hidden' ); + + $btn.click(); + + $input.val( str ); + + // Enable editing + expect( $input ).not.toHaveClass( 'is-hidden' ); + expect( $input ).toHaveClass( 'edit' ); + }; + + keyPress = function( $el, key ) { + $el.trigger({ + type: 'keydown', + keyCode: key, + which: key, + charCode: key + }); + }; + + openPublishModal = function() { + var $publishBtn = view.$el.find('.js-publish-program'), + defaultStatus = programData.status, + publishedStatus = 'active'; + + expect( view.modalView ).not.toBeDefined(); + expect( view.model.get( 'status' ) ).toEqual( defaultStatus ); + expect( view.model.get( 'status' ) ).not.toEqual( publishedStatus ); + + $publishBtn.click(); + }; + + resetCourseCodes = function() { + var originalRun = programData.course_codes[0]; + + programData.course_codes = [originalRun]; + }; + + testUnchangedFieldBlur = function( el ) { + var $input = view.$el.find( el ), + $btn = view.$el.find( '.js-add-course' ), + title = $input.val(), + update = title; + + editField( el, update ); + $btn.focus(); + $input.blur(); + + expect( title ).toEqual( update ); + expect( view.model.save ).not.toHaveBeenCalled(); + }; + + testUpdatedFieldBlur = function( el, update ) { + var $input = view.$el.find( el ), + $btn = view.$el.find( '.js-add-course' ); + + expect( $input.val() ).not.toEqual( update ); + + editField( el, update ); + + $btn.focus(); + $input.blur(); + + expect( $input.val() ).toEqual( update ); + expect( view.model.save ).toHaveBeenCalled(); + }; + + testHidingButtonsAfterPublish = function( el ) { + expect( view.$el.find( el ).length ).toBeGreaterThan( 0 ); + view.model.set({ status: 'active' }); + view.render(); + expect( view.$el.find( el ).length ).toEqual( 0 ); + }; + + testInvalidUpdate = function( el, update ) { + var $input = view.$el.find( el ), + $btn = view.$el.find( '.js-add-course' ); + + editField( el, update ); + + $btn.focus(); + $input.blur(); + + expect( $input ).toHaveClass( errorClass ); + expect( view.model.save ).not.toHaveBeenCalled(); + }; + + beforeEach( function() { + // Set the DOM + setFixtures( '<div class="js-program-admin"></div>' ); + + jasmine.clock().install(); + + spyOn( ProgramModel.prototype, 'set' ).and.callThrough(); + spyOn( ProgramModel.prototype, 'save' ); + spyOn( CourseRunsCollection.prototype, 'fetch' ); + + model = new ProgramModel(); + model.set( programData ); + + view = new ProgramDetailsView({ + model: model + }); + + view.courseRuns.set( courseRunsList ); + view.courseRuns.parse({ results: courseRunsList }); + }); + + afterEach( function() { + resetCourseCodes(); + view.undelegateEvents(); + view.remove(); + + jasmine.clock().uninstall(); + }); + + describe( 'View data', function() { + it( 'should exist', function () { + expect( view ).toBeDefined(); + }); + + it( 'should render all of the run_modes from the model', function() { + var $runs = view.$el.find('.js-course-runs'), + domLength = $runs.find('.js-remove-run').length, + objLength = programData.course_codes[0].run_modes.length; + + expect( domLength ).toEqual( objLength ); + }); + }); + + describe( 'Delete data', function() { + it( 'should remove a course when the delete button is clicked', function() { + var $el = view.$el.find('.js-course-list'), + $removeRunBtn = $el.find('.js-remove-course').first(), + count = programData.course_codes.length; + + expect( $el.find('.js-remove-course').length ).toEqual( count ); + $removeRunBtn.click(); + + setTimeout( function() { + expect( $el.find('.js-remove-course').length ).toEqual( count - 1 ); + }, testTimeoutInterval ); + + jasmine.clock().tick( testTimeoutInterval + 1 ); + }); + + it( 'should remove a course run when the delete button is clicked', function() { + var $runs = view.$el.find('.js-course-runs'), + $removeRunBtn = $runs.find('.js-remove-run').first(), + count = programData.course_codes[0].run_modes.length; + + expect( $runs.find('.js-remove-run').length ).toEqual( count ); + $removeRunBtn.click(); + + setTimeout( function() { + expect( $runs.find('.js-remove-run').length ).toEqual( count - 1 ); + }, testTimeoutInterval ); + + jasmine.clock().tick( testTimeoutInterval + 1 ); + }); + + it( 'should not show the delete course button if program status is not unpublished', function() { + testHidingButtonsAfterPublish('.js-remove-course'); + }); + + it( 'should not show the add course button if program status is not unpublished', function() { + testHidingButtonsAfterPublish('.js-add-course'); + }); + + it( 'should not show the delete run button if program status is not unpublished', function() { + testHidingButtonsAfterPublish('.js-remove-run'); + }); + }); + + describe( 'Add data', function() { + it( 'should add a new course details view on click of the add course button', function() { + var $btn = view.$el.find('.js-add-course').first(); + + expect( view.$('.js-course-form').length ).toEqual( 0 ); + $btn.click(); + expect( view.$('.js-course-form').length ).toEqual( 1 ); + }); + + it( 'should add a course when the form is submitted', function() { + addCourse(); + }); + + it( 'should not submit the course form when it is incomplete', function() { + var $addCourseBtn = view.$el.find('.js-add-course').first(), + $form, + $submitBtn; + + expect( view.$el.find('.course-details').length ).toEqual( 1 ); + $addCourseBtn.click(); + $form = view.$('.js-course-form'); + expect( $form.find('.field-message.has-error').length ).toEqual( 0 ); + $submitBtn = $form.find('.js-select-course'); + $submitBtn.click(); + expect( $form.find('.field-message.has-error').length ).toEqual( 2 ); + }); + + it( 'should allow a user to add a course run', function() { + var runSelect = '.js-course-run-select', + $runSelect, + savedRunCount = programData.course_codes[0].run_modes.length; + + addCourse(); + expect( view.$(runSelect).length ).toEqual(0); + view.$('.js-add-course-run').first().click(); + + $runSelect = view.$(runSelect); + expect( $runSelect.length ).toEqual(1); + expect( view.$('.js-remove-run').length ).toEqual(savedRunCount); + dropdownSelect($runSelect, courseRunsList[0].id); + expect( view.$(runSelect).length ).toEqual(0); + expect( view.$('.js-remove-run').length ).toEqual(savedRunCount + 1); + }); + + it( 'should update the course run dropdown if multiple are open and one is selected', function() { + var runSelect = '.js-course-run-select', + $addRunBtn, + $courseView, + courseRunOptionsCount = courseRunsList.length + 1; + + addCourse(); + expect( view.$(runSelect).length ).toEqual(0); + + $courseView = view.$('.course-container').last(); + $addRunBtn = $courseView.find('.js-add-course-run'); + $addRunBtn.click(); + + expect( view.$(runSelect).length ).toEqual(1); + expect( view.$(runSelect).find('option').length ).toEqual(courseRunOptionsCount); + + $addRunBtn.click(); + expect( view.$(runSelect).length ).toEqual(2); + + dropdownSelect(view.$(runSelect).first(), courseRunsList[0].id); + expect( view.$(runSelect).length ).toEqual(1); + expect( view.$(runSelect).find('option').length ).toEqual(courseRunOptionsCount - 1); + }); + }); + + describe( 'Edit data', function() { + it( 'should enable a user to edit the name, subtitle and marketing slug fields', function() { + editField( '.program-name', 'name' ); + editField( '.program-subtitle', 'subtitle' ); + editField( '.program-marketing-slug', 'marketing-slug' ); + }); + + it( 'should not send an API call if a user does not change the value of an editable field', function() { + testUnchangedFieldBlur( '.program-name' ); + testUnchangedFieldBlur( '.program-subtitle' ); + testUnchangedFieldBlur( '.program-marketing-slug' ); + }); + + it( 'should send an API call if a user changes the value of an editable field', function() { + testUpdatedFieldBlur( '.program-name', 'new-title' ); + testUpdatedFieldBlur( '.program-subtitle', 'new-subtitle' ); + testUpdatedFieldBlur( '.program-marketing-slug', 'new-marketing-slug' ); + }); + + it( 'should show error messaging if the updated required field is empty', function() { + testInvalidUpdate( '.program-name', '' ); + }); + + it( 'should show error messaging if the updated field value is too long', function() { + var chars256 = 'x'.repeat(256); + + testInvalidUpdate( '.program-name', chars256 ); + testInvalidUpdate( '.program-subtitle', chars256 ); + testInvalidUpdate( '.program-marketing-slug', chars256 ); + }); + + it( 'should create a POST config object by default', function() { + var config = view.model.getConfig(); + + expect( config.type ).toEqual( 'POST' ); + expect( config.contentType ).toEqual( 'application/json' ); + expect( config.data ).not.toBeDefined(); + }); + + it( 'should create a PATCH config object when passed in object sets patch as true', function() { + var data = { name: 'patched name' }, + config = view.model.getConfig({ + patch: true, + update: data + }); + + expect( config.type ).toEqual( 'PATCH' ); + expect( config.contentType ).toEqual( 'application/merge-patch+json' ); + expect( config.data ).toBeDefined(); + expect( config.data ).toEqual( JSON.stringify( data ) ); + }); + }); + + describe( 'Publish a Program', function() { + it( 'should open the publish modal when the publish button is clicked', function() { + openPublishModal(); + expect( view.modalView ).toBeDefined(); + }); + + it( 'should publish a program when the publish confirm button is clicked', function() { + var defaultStatus = programData.status, + publishedStatus = 'active'; + + openPublishModal(); + expect( view.modalView ).toBeDefined(); + + view.$el.find('.js-confirm').click(); + + // Model should be set and save called + expect( view.model.set ).toHaveBeenCalled(); + expect( view.model.get( 'status' ) ).not.toEqual( defaultStatus ); + expect( view.model.get( 'status' ) ).toEqual( publishedStatus ); + expect( view.model.save ).toHaveBeenCalled(); + + // Publish button should be removed once API has completed its call + expect( view.$el.find('.js-publish-program').length ).toEqual( 1 ); + view.model.trigger( 'sync' ); + expect( view.$el.find('.js-publish-program').length ).toEqual( 0 ); + }); + + it( 'should show a validation error when publish button pressed if validation fails', function() { + var $input = view.$el.find( '#program-marketing-slug' ); + + view.model.set('marketing_slug', ''); + openPublishModal(); + expect( view.modalView ).not.toBeDefined(); + expect( $input ).toHaveClass( errorClass ); + }); + + it( 'should destroy the publish modal when the cancel button is clicked', function() { + openPublishModal(); + expect( view.modalView ).toBeDefined(); + + // Close the modal + view.$el.find('.js-cancel').click(); + + // Expect the modal DOM elements to not be there anymore + expect( view.$el.find('.js-cancel').length ).toEqual( 0 ); + expect( view.modalView.$parentEl.html().length ).toEqual( 0 ); + }); + + it( 'should destroy the publish modal when the esc key is pressed', function() { + openPublishModal(); + expect( view.modalView ).toBeDefined(); + + // Close the modal + keyPress( view.modalView.$el, constants.keyCodes.esc ); + + // Expect the modal DOM elements to not be there anymore + expect( view.$el.find('.js-cancel').length ).toEqual( 0 ); + expect( view.modalView.$parentEl.html().length ).toEqual( 0 ); + }); + }); + }); + } +); diff --git a/cms/static/sass/_base-v2.scss b/cms/static/sass/_base-v2.scss new file mode 100644 index 00000000000..14a6c676a10 --- /dev/null +++ b/cms/static/sass/_base-v2.scss @@ -0,0 +1,15 @@ +// studio - base styling +// ==================== +html { + height: 102%; // force scrollbar to prevent jump when scroll appears, cannot use overflow because it breaks drag +} + +body { + min-width: $fg-min-width; + background: $gray-l5; + color: $gray-d2; +} + +footer.primary{ + font-size: font-size(x-small); +} diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index a8dd687fb67..d86b59869b5 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -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 // ==================== diff --git a/cms/static/sass/_build-v1.scss b/cms/static/sass/_build-v1.scss index 36c86d4d340..e65d9ee191b 100644 --- a/cms/static/sass/_build-v1.scss +++ b/cms/static/sass/_build-v1.scss @@ -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 // ==================== diff --git a/cms/static/sass/_build-v2.scss b/cms/static/sass/_build-v2.scss index 2a032042f63..7335783fbd7 100644 --- a/cms/static/sass/_build-v2.scss +++ b/cms/static/sass/_build-v2.scss @@ -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'; diff --git a/cms/static/sass/_mixins-v2.scss b/cms/static/sass/_mixins-v2.scss new file mode 100644 index 00000000000..86031b4c7ef --- /dev/null +++ b/cms/static/sass/_mixins-v2.scss @@ -0,0 +1,4 @@ +// pill button +%ui-btn-pill { + border-radius: ($baseline/5); +} diff --git a/cms/static/sass/elements-v2/_controls.scss b/cms/static/sass/elements-v2/_controls.scss new file mode 100644 index 00000000000..69116366ad4 --- /dev/null +++ b/cms/static/sass/elements-v2/_controls.scss @@ -0,0 +1,45 @@ +// +UI Dropdown Button - Extend +// ==================== +%ui-btn-dd { + @extend %ui-btn; + @extend %ui-btn-pill; + padding:($baseline/4) ($baseline/2); + border-width: 1px; + border-style: solid; + border-color: transparent; + text-align: center; + + &:hover, &:active { + @extend %ui-fake-link; + border-color: $gray-l3; + } + + &.current, &.active, &.is-selected { + box-shadow: inset 0 1px 2px 1px $shadow-l1; + border-color: $gray-l3; + } +} + +// +UI Nav Dropdown Button - Extend +// ==================== +%ui-btn-dd-nav-primary { + @extend %ui-btn-dd; + background: $white; + border-color: $white; + color: $gray-d1; + + &:hover, &:active { + background: $white; + color: $blue-s1; + } + + &.current, &.active { + background: $white; + color: $gray-d4; + + &:hover, &:active { + color: $blue-s1; + } + } +} + diff --git a/cms/static/sass/elements-v2/_header.scss b/cms/static/sass/elements-v2/_header.scss new file mode 100644 index 00000000000..f04a3d93c91 --- /dev/null +++ b/cms/static/sass/elements-v2/_header.scss @@ -0,0 +1,172 @@ +// studio - elements - global header +// ==================== + +.wrapper-header { + position: relative; + width: 100%; + box-shadow: 0 1px 2px 0 $shadow-l1; + margin: 0; + padding: 0 $baseline; + background: $white; + + header.primary { + @include clearfix(); + @include span(12); + @include float(none); + box-sizing: border-box; + max-width: $fg-max-width; + min-width: $fg-min-width; + margin: 0 auto; + } + + // ==================== + + // basic layout + .wrapper-l, .wrapper-r { + background: $white; + } + + .wrapper-l { + @include span(7); + } + + .wrapper-r { + @include span(4 last); + @include text-align(right); + } + + .branding, .info-course, .nav-course, .nav-account, .nav-pitch { + box-sizing: border-box; + display: inline-block; + vertical-align: middle; + } + + .user-language-selector { + width: 120px; + display: inline-block; + margin: 0 10px 0 5px; + vertical-align: sub; + + .language-selector { + width: 120px; + } + } + + .nav-account { + width: auto; + } + + // basic layout - nav items + .nav-dd { + .nav-item { + display: inline-block; + vertical-align: middle; + + &:last-child { + margin-right: 0; + } + + .title{ + @extend %ui-btn-dd-nav-primary; + @include transition(all $tmg-f2 ease-in-out 0s); + line-height: 16px; + margin-top: 6px; + font-size: font-size(base); + font-weight: font-weight(semi-bold); + .nav-sub .nav-item { + .icon { + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + } + } + } + + .nav-item a { + color: $gray-d1; + + &:hover, + &:focus { + color: $blue-s1; + } + } + } + + // ==================== + + // specific elements - branding + .branding { + padding: ($baseline*0.75) 0; + + .brand-link { + display: block; + + .brand-image { + max-height: ($baseline*2); + display: block; + } + } + } + + // ==================== + + // specific elements - account-based nav + .nav-account { + position: relative; + padding: ($baseline*0.75) 0; + + .nav-sub { + @include text-align(left); + } + + .nav-account-help { + + .wrapper-nav-sub { + width: ($baseline*10); + } + } + + .nav-account-user { + + .title { + max-width: ($baseline*6.5); + display: inline-block; + max-width: 84%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .settings-language-form { + margin-top: 4px; + .language-selector { + width: 130px; + } + } + } +} +// ==================== + +// CASE: user signed in +.is-signedin { + + .wrapper-l { + width: flex-grid(8,12); + } + + .wrapper-r { + width: flex-grid(4,12); + } + + .branding { + @include margin-right(2%); + } + + .nav-account { + top: ($baseline/4); + } +} + + diff --git a/cms/static/sass/elements-v2/_navigation.scss b/cms/static/sass/elements-v2/_navigation.scss new file mode 100644 index 00000000000..ce4b43225cf --- /dev/null +++ b/cms/static/sass/elements-v2/_navigation.scss @@ -0,0 +1,22 @@ +// skip navigation +.nav-skip, +.transcript-skip { + @include left(0); + font-size: font-size(small); + display: inline-block; + position: absolute; + top: -($baseline*30); + overflow: hidden; + background: $white; + border-bottom: 1px solid $gray-l4; + padding: ($baseline*0.75) ($baseline/2); + + &:focus, + &:active { + position: relative; + top: auto; + width: auto; + height: auto; + margin: 0; + } +} diff --git a/cms/static/sass/elements-v2/_sock.scss b/cms/static/sass/elements-v2/_sock.scss new file mode 100644 index 00000000000..c34daf795a4 --- /dev/null +++ b/cms/static/sass/elements-v2/_sock.scss @@ -0,0 +1,135 @@ +// studio - elements - support sock +// ==================== + +.wrapper-sock { + @include clearfix(); + position: relative; + margin: ($baseline*2) 0 0 0; + border-top: 1px solid $gray-l4; + width: 100%; + + .wrapper-inner { + @include linear-gradient($gray-d3 0%, $gray-d3 98%, $black 100%); + display: none; + width: 100% !important; + border-bottom: 1px solid $white; + padding: 0 $baseline !important; + } + + // sock - actions + .list-cta { + @extend %ui-depth1; + position: absolute; + top: -($baseline*0.75); + width: 100%; + margin: 0 auto; + text-align: center; + + .cta-show-sock { + @extend %ui-btn-pill; + background: $gray-l5; + font-size: font-size(x-small); + padding: ($baseline/2) $baseline; + color: $gray; + + .icon { + @include margin-right($baseline/4); + } + + &:hover { + background: $blue; + color: $white; + } + } + } + + // sock - additional help + .sock { + @include clearfix(); + @include span(12); + max-width: $fg-max-width; + min-width: $fg-min-width; + margin: 0 auto; + padding: ($baseline*2) 0; + color: $gray-l3; + + // shared elements + .support, + .feedback { + box-sizing: border-box; + + .title { + color: $white; + margin-bottom: ($baseline/2); + } + + .copy { + margin: 0 0 $baseline 0; + } + + .list-actions { + list-style: none; + + .action-item { + @include float(left); + @include margin-right($baseline/2); + margin-bottom: ($baseline/2); + + &:last-child { + @include margin-right(0); + } + + .action { + display: block; + + .icon { + vertical-align: middle; + @include margin-right($baseline/4); + } + } + + .tip { + @extend .sr-only; + } + } + + .action-primary { + @extend %btn-brand; + @extend %btn-small; + } + } + } + + // studio support content + .support { + @include float(left); + @include span(8); + margin-right: flex-gutter(); + + .action-item { + width: flexgrid(4,8); + } + } + + // studio feedback content + .feedback { + @include float(left); + @include span(4); + .action-item { + width: flexgrid(4,4); + } + } + } + + // case: sock content is shown + &.is-shown { + border-color: $gray-d3; + + .list-cta .cta-show-sock { + background: $gray-d3; + border-color: $gray-d3; + color: $white; + font-size: font-size(small); + } + } +} diff --git a/cms/static/sass/elements-v2/_tooltip.scss b/cms/static/sass/elements-v2/_tooltip.scss new file mode 100644 index 00000000000..ef826da6441 --- /dev/null +++ b/cms/static/sass/elements-v2/_tooltip.scss @@ -0,0 +1,25 @@ +.tooltip { + @include transition(opacity $tmg-f3 ease-out 0s); + position: absolute; + top: 0; + left: 0; + padding: 0 10px; + border-radius: 3px; + background: $black-t4; + line-height: 26px; + font-size: font-size(x-small); + color: $white; + pointer-events: none; + opacity: 0; + + &:after { + font-size: font-size(x-large); + content: 'â–¾'; + display: block; + position: absolute; + bottom: -14px; + left: 50%; + margin-left: -7px; + color: $black-t4; + } +} diff --git a/cms/static/sass/elements/_tooltip.scss b/cms/static/sass/elements/_tooltip.scss new file mode 100644 index 00000000000..77c42d63de3 --- /dev/null +++ b/cms/static/sass/elements/_tooltip.scss @@ -0,0 +1,27 @@ +.tooltip { + @include transition(opacity $tmg-f3 ease-out 0s); + @include font-size(12); + @extend %t-regular; + @extend %ui-depth5; + position: absolute; + top: 0; + left: 0; + padding: 0 10px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.85); + line-height: 26px; + color: $white; + pointer-events: none; + opacity: 0.0; + + &:after { + @include font-size(20); + content: 'â–¾'; + display: block; + position: absolute; + bottom: -14px; + left: 50%; + margin-left: -7px; + color: rgba(0, 0, 0, 0.85); + } +} \ No newline at end of file diff --git a/cms/static/sass/partials/_variables.scss b/cms/static/sass/partials/_variables.scss index 4e9f1dac9db..44931cdb0ca 100644 --- a/cms/static/sass/partials/_variables.scss +++ b/cms/static/sass/partials/_variables.scss @@ -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); diff --git a/cms/static/sass/programs/_app-container.scss b/cms/static/sass/programs/_app-container.scss new file mode 100644 index 00000000000..b53b3291267 --- /dev/null +++ b/cms/static/sass/programs/_app-container.scss @@ -0,0 +1,10 @@ + +// ------------------------------ +// Programs: App Container + +// About: styling for setting up the wrapper. +.program-app { + &.layout-1q3q { + max-width: 1250px; + } +} diff --git a/cms/static/sass/programs/_build.scss b/cms/static/sass/programs/_build.scss new file mode 100644 index 00000000000..272dcbe63fd --- /dev/null +++ b/cms/static/sass/programs/_build.scss @@ -0,0 +1,9 @@ +// ------------------------------ +// Programs: Main Style Compile + +// About: Sass compile for the Programs IDA. + +@import 'components'; +@import 'views'; +@import 'modals'; +@import 'app-container'; diff --git a/cms/static/sass/programs/_components.scss b/cms/static/sass/programs/_components.scss new file mode 100644 index 00000000000..b59e018bb93 --- /dev/null +++ b/cms/static/sass/programs/_components.scss @@ -0,0 +1,99 @@ +// ------------------------------ +// Programs: Components + +// About: styling for specific UI components ranging from global to modular. + +// #BUTTONS +// #FORMS + + +// ------------------------------ +// #BUTTONS +// ------------------------------ +.btn { + &.btn-delete, + &.btn-edit { + border: none; + background: none; + color: palette(grayscale, base); + + &:hover, + &:focus, + &:active { + color: palette(grayscale, black); + } + } + + &.full { + width: 100%; + } + + &.right { + @include float(right); + } + + &.btn-create { + background: palette(success, base); + border-color: palette(success, base); + + // STATE: hover and focus + &:hover, + &.is-hovered, + &:focus, + &.is-focused { + background: shade($success, 33%); + color: $btn-default-focus-color; + } + + // STATE: is pressed or active + &:active, + &.is-pressed, + &.is-active { + border-color: shade($success, 33%); + background: shade($success, 33%); + } + + .text { + margin-left: 5px; + } + } + + .icon, + .text { + vertical-align: middle; + } + + .icon { + font-size: 16px; + } +} + +// ------------------------------ +// #FORMS +// ------------------------------ +.field { + .invalid { + border: 2px solid palette(error, base); + } + + .field-input, + .field-hint, + .field-message { + min-with: 300px; + width: 50%; + + &.is-hidden { + @extend .is-hidden; + } + } + + .copy { + vertical-align: middle; + } +} + +.form-group { + &.bg-white { + background-color: palette(grayscale, white); + } +} diff --git a/cms/static/sass/programs/_modals.scss b/cms/static/sass/programs/_modals.scss new file mode 100644 index 00000000000..890e609dccc --- /dev/null +++ b/cms/static/sass/programs/_modals.scss @@ -0,0 +1,76 @@ +// ------------------------------ +// Programs: Modals + +// About: styling for modals. +.modal-window-overlay { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: palette(grayscale-cool, x-dark); + opacity: 0.5; + z-index: 1000; +} + +.modal-window { + position: absolute; + background-color: palette(grayscale, black); + width: 80%; + left: 10%; + top: 40%; + z-index: 1001; +} + +.modal-content { + margin: 5px; + padding: 20px; + background-color: palette(grayscale-cool, x-dark); + border-top: 5px solid palette(warning, base); + + .copy { + color: palette(grayscale, white); + } + + .emphasized { + color: palette(grayscale, white-t); + font-weight: font-weight(bold); + } +} + +.modal-actions { + padding: 10px 20px; + + .btn { + color: palette(grayscale, white-t); + } + + .btn-brand { + background: palette(warning, base); + border-color: palette(warning, base); + + &:hover, + &:focus, + &:active { + background: palette(warning, dark); + border-color: palette(warning, dark);; + } + } + + .btn-neutral { + background: transparent; + border-color: transparent; + &:hover, + &:focus, + &:active { + border-color: palette(grayscale-cool, light) + } + } +} + +@include breakpoint( $bp-screen-sm ) { + .modal-window { + width: 440px; + left: calc( 50% - 220px ); + } +} diff --git a/cms/static/sass/programs/_views.scss b/cms/static/sass/programs/_views.scss new file mode 100644 index 00000000000..a4b9f282fe4 --- /dev/null +++ b/cms/static/sass/programs/_views.scss @@ -0,0 +1,62 @@ + +// ------------------------------ +// Programs: Views + +// About: styling for specific views. + +// ------------------------------ +// #PROGRAM LISTS +// ------------------------------ +.program-list { + list-style-type: none; + padding-left: 0; + + .program-details { + .name { + font-size: 2rem; + } + + .status { + @include float(right); + } + + .category { + color: palette(grayscale, base); + } + } +} + +.app-header { + @include clearfix(); + border-bottom: 1px solid palette(grayscale, base); + margin-bottom: 20px; +} + +.course-container { + .subtitle { + color: palette(grayscale, base); + } +} + +.run-container { + position: relative; + margin: { + bottom: 20px; + }; + + &:before { + content: ''; + width: 5px; + height: calc( 100% + 1px ); + background: palette(grayscale, base); + position: absolute; + top: 0; + left: 0; + } +} + +.course-container { + margin: { + bottom: 20px; + }; +} diff --git a/cms/static/sass/studio-main-v2.scss b/cms/static/sass/studio-main-v2.scss index cd355d5a7d1..f31bcb3817b 100644 --- a/cms/static/sass/studio-main-v2.scss +++ b/cms/static/sass/studio-main-v2.scss @@ -12,3 +12,4 @@ $pattern-library-path: '../edx-pattern-library' !default; // Load the shared build @import 'build-v2'; +@import 'programs/build'; diff --git a/cms/templates/js/programs/confirm_modal.underscore b/cms/templates/js/programs/confirm_modal.underscore new file mode 100644 index 00000000000..cafe8978376 --- /dev/null +++ b/cms/templates/js/programs/confirm_modal.underscore @@ -0,0 +1,24 @@ +<div class="wrapper wrapper-modal-window wrapper-modal-window-<%- name %>" + aria-describedby="modal-window-description" + aria-labelledby="modal-window-title" + aria-hidden="" + role="dialog"> + <div class="modal-window-overlay"></div> + <div class="js-focus-first modal-window modal-medium modal-type-confirm" tabindex="-1" aria-labelledby="modal-window-title"> + <div class="<%- name %>-modal"> + <div class="modal-content"> + <span class="copy copy-lead emphasized"><%- title %></span> + <p class="copy copy-base"><%- body %></p> + </div> + <div class="modal-actions"> + <h3 class="sr-only"><%- gettext('Actions') %></h3> + <button class="js-confirm btn btn-brand btn-base"> + <span><%- cta.confirm %></span> + </button> + <button class="js-cancel btn btn-neutral btn-base"> + <span><%- cta.cancel %></span> + </button> + </div> + </div> + </div> +</div> diff --git a/cms/templates/js/programs/course_details.underscore b/cms/templates/js/programs/course_details.underscore new file mode 100644 index 00000000000..42aa7684e09 --- /dev/null +++ b/cms/templates/js/programs/course_details.underscore @@ -0,0 +1,49 @@ +<div class="card course-container"> +<% if ( display_name ) { %> + <span class="copy copy-large emphasized"><%- display_name %></span> + <% if ( status === 'unpublished' ) { %> + <button class="js-remove-course btn btn-delete right" data-tooltip="<%- gettext('Delete course') %>"> + <span class="icon fa fa-trash-o" aria-hidden="true"></span> + <span class="sr-only"><%- interpolate( + gettext('Remove %(name)s from the program'), + { name: display_name }, + true + ) %></span> + </button> + <% } %> + <p class="copy copy-base subtitle"><%- organization.display_name %> / <%- key %> + <div class="js-course-runs"></div> + <% if ( courseRuns.length > -1 ) { %> + <button class="js-add-course-run btn btn-neutral btn-base full"> + <span class="icon fa fa-plus" aria-hidden="true"></span> + <span class="text"><%- gettext('Add another run') %></span> + </button> + <% } %> +<% } else { %> + <form class="form js-course-form"> + <fieldset class="form-group"> + <div class="field"> + <label class="field-label" for="course-key-<%- cid %>"><%- gettext('Course Code') %></label> + <input id="course-key-<%- cid %>" class="field-input input-text course-key" name="key" aria-describedby="course-key-<%- cid %>-desc" maxlength="255" required> + <div class="field-message"> + <span class="field-message-content"></span> + </div> + <div class="field-hint" id="course-key-<%- cid %>-desc"> + <p><%- gettext('The unique number that identifies your course within your organization, e.g. CS101.') %></p> + </div> + </div> + <div class="field"> + <label class="field-label" for="display-name-<%- cid %>"><%- gettext('Course Title') %></label> + <input id="display-name-<%- cid %>" class="field-input input-text display-name" name="display_name" aria-describedby="display-name-<%- cid %>-desc" maxlength="255" required> + <div class="field-message"> + <span class="field-message-content"></span> + </div> + <div class="field-hint" id="display-name-<%- cid %>-desc"> + <p><%- gettext('The title entered here will override the title set for the individual run of the course. It will be displayed on the XSeries progress page and in marketing presentations.') %></p> + </div> + </div> + <button class="btn btn-primary js-select-course"><%- gettext('Save Course') %></button> + </fieldset> + </form> +<% } %> +</div> diff --git a/cms/templates/js/programs/course_run.underscore b/cms/templates/js/programs/course_run.underscore new file mode 100644 index 00000000000..11264a8f96e --- /dev/null +++ b/cms/templates/js/programs/course_run.underscore @@ -0,0 +1,36 @@ +<div class="card run-container"> + <% if ( !_.isUndefined(course_key) ) { %> + <span class="copy copy-large emphasized"><%- interpolate( + gettext('Run %(key)s'), + { key: course_key }, + true + ) %></span> + <% if ( programStatus === 'unpublished' ) { %> + <button class="js-remove-run btn btn-delete right" data-tooltip="<%- gettext('Delete course run') %>"> + <span class="icon fa fa-trash-o" aria-hidden="true"></span> + <span class="sr-only"><%- interpolate( + gettext('Remove run %(key)s from the program'), + { key: course_key }, + true + ) %></span> + </button> + <% } %> + <div class="copy copy-base subtitle"><%- interpolate( + gettext('Start Date: %(date)s'), + { date: start_date }, + true + ) %></div> + <div class="copy copy-base subtitle"><%- interpolate( + gettext('Mode: %(mode)s'), + { mode: mode_slug }, + true + ) %></div> + <% } else { %> + <select class="js-course-run-select"> + <option><%- gettext('Please select a Course Run') %></option> + <% _.each(courseRuns, function(run) { %> + <option value="<%- run.id %>"><%- run.name %>: <%- run.id %></option> + <% }); %> + </select> + <% } %> +</div> diff --git a/cms/templates/js/programs/program_creator_form.underscore b/cms/templates/js/programs/program_creator_form.underscore new file mode 100644 index 00000000000..a4db9d6b5da --- /dev/null +++ b/cms/templates/js/programs/program_creator_form.underscore @@ -0,0 +1,65 @@ +<h3 class="hd-3 emphasized"><%- gettext('Create a New Program') %></h3> +<form class="form"> + <fieldset class="form-group bg-white"> + <div class="field"> + <label class="field-label" for="program-type"><%- gettext('Program type') %></label> + <select id="program-type" class="field-input input-select program-type" name="category" disabled> + <option value="xseries"><%- gettext('XSeries') %></option> + </select> + <div class="field-message"> + <span class="field-message-content"></span> + </div> + </div> + + <div class="field"> + <label class="field-label" for="program-org"><%- gettext('Organization') %></label> + <select id="program-org" class="field-input input-select program-org" name="organizations"> + <option value="false"><%- gettext('Select an organization') %></option> + <% _.each( orgs, function( org ) { %> + <option value="<%- org.key %>"><%- org.display_name %></option> + <% }); %> + </select> + <div class="field-message"> + <span class="field-message-content"></span> + </div> + </div> + + <div class="field"> + <label class="field-label" for="program-name"><%- gettext('Name') %></label> + <input id="program-name" class="field-input input-text program-name" name="name" maxlength="64" aria-describedby="program-name-desc" required> + <div class="field-message"> + <span class="field-message-content"></span> + </div> + <div class="field-hint" id="program-name-desc"> + <p><%- gettext('The public display name of the program.') %></p> + </div> + </div> + + <div class="field"> + <label class="field-label" for="program-subtitle"><%- gettext('Subtitle') %></label> + <input id="program-subtitle" class="field-input input-text program-subtitle" name="subtitle" maxlength="255" aria-describedby="program-subtitle-desc"> + <div class="field-message"> + <span class="field-message-content"></span> + </div> + <div class="field-hint" id="program-subtitle-desc"> + <p><%- gettext('A short description of the program, including concepts covered and expected outcomes (255 character limit).') %></p> + </div> + </div> + + <div class="field"> + <label class="field-label" for="program-marketing-slug"><%- gettext('Marketing Slug') %></label> + <input id="program-marketing-slug" class="field-input input-text program-marketing-slug" name="marketing_slug" maxlength="255" aria-describedby="program-marketing-slug-desc"> + <div class="field-message"> + <span class="field-message-content"></span> + </div> + <div class="field-hint" id="program-marketing-slug-desc"> + <p><%- gettext('Slug used to generate links to the marketing site.') %></p> + </div> + </div> + + <div class="field"> + <button class="btn btn-brand btn-base js-create-program"><%- gettext('Create') %></button> + <button class="btn btn-neutral btn-base js-abort-view"><%- gettext('Cancel') %></button> + </div> + </fieldset> +</form> diff --git a/cms/templates/js/programs/program_details.underscore b/cms/templates/js/programs/program_details.underscore new file mode 100644 index 00000000000..bb606fb03b8 --- /dev/null +++ b/cms/templates/js/programs/program_details.underscore @@ -0,0 +1,63 @@ +<header class="app-header"> + <form> + <div class="layout-col layout-col-b"> + <div class="js-inline-edit field"> + <span class="js-model-value copy copy-large emphasized"><%- name %></span> + <label for="program-name" class="sr-only"><%- gettext('Name') %></label> + <input type="text" value="<%- name %>" id="program-name" class="program-name field-input is-hidden" name="name" data-field="name" maxlength="64" required> + <button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program title') %>"> + <span class="icon fa fa-pencil" aria-hidden="true"></span> + <span class="sr-only"><%- gettext('Edit the program\'s name.') %></span> + </button> + <div class="field-message"> + <span class="field-message-content"></span> + </div> + </div> + + <div class="js-inline-edit field"> + <span class="js-model-value copy copy-base subtitle"><%- subtitle %></span> + <label for="program-subtitle" class="sr-only"><%- gettext('Subtitle') %></label> + <input type="text" value="<%- subtitle %>" id="program-subtitle" class="program-subtitle field-input is-hidden" name="subtitle" data-field="subtitle" maxlength="255"> + <button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program subtitle') %>"> + <span class="icon fa fa-pencil" aria-hidden="true"></span> + <span class="sr-only"><%- gettext('Edit the program\'s subtitle.') %></span> + </button> + <div class="field-message"> + <span class="field-message-content"></span> + </div> + </div> + + <div class="js-inline-edit field"> + <span class="js-model-value copy copy-base subtitle"><%- marketing_slug %></span> + <label for="program-subtitle" class="sr-only"><%- gettext('Marketing Slug') %></label> + <input type="text" value="<%- marketing_slug %>" id="program-marketing-slug" class="program-marketing-slug field-input is-hidden" name="marketing_slug" data-field="marketing_slug" maxlength="255"> + <button class="js-enable-edit btn btn-edit" data-tooltip="<%- gettext('Edit the program marketing slug') %>"> + <span class="icon fa fa-pencil" aria-hidden="true"></span> + <span class="sr-only"><%- gettext('Edit the program\'s marketing slug.') %></span> + </button> + <div class="field-message"> + <span class="field-message-content"></span> + </div> + </div> + </div> + + <div class="layout-col layout-col-a"> + <% if ( status === 'unpublished' ) { %> + <button class="js-publish-program btn btn-neutral btn-base btn-grey right"> + <span><%- gettext('Publish') %></span> + </button> + <% } %> + </div> + </form> +</header> +<div class="layout-col layout-col-b"> + <div class="js-course-list"></div> + <% if ( status === 'unpublished' ) { %> + <button class="js-add-course btn btn-neutral btn-base full"> + <span class="icon fa fa-plus" aria-hidden="true"></span> + <span class="text"><%- gettext('Add a course') %></span> + </button> + <% } %> +</div> +<aside class="js-aside layout-col layout-col-a"></aside> +<div class="js-publish-modal"></div> diff --git a/cms/templates/program_authoring.html b/cms/templates/program_authoring.html index 4c7d2e8bf8a..0f15d942207 100644 --- a/cms/templates/program_authoring.html +++ b/cms/templates/program_authoring.html @@ -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"> diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 2602adff51d..8e91d520163 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -10,9 +10,11 @@ <header class="primary" role="banner"> <div class="wrapper wrapper-l"> - <h1 class="branding"><a href="/"> - <img src="${static.url('images/studio-logo.png')}" alt="${settings.STUDIO_NAME}" /> - </a></h1> + <h1 class="branding"> + <a class="brand-link" href="/"> + <img class="brand-image" src="${static.url('images/studio-logo.png')}" alt="${settings.STUDIO_NAME}" /> + </a> + </h1> % if context_course: <% @@ -215,33 +217,20 @@ % endif % if user.is_authenticated(): <nav class="nav-account nav-is-signedin nav-dd ui-right" aria-label="${_('Account')}"> - <h2 class="sr">${_("Account Navigation")}</h2> + <h2 class="sr-only">${_("Account Navigation")}</h2> <ol> <li class="nav-item nav-account-help"> <h3 class="title"><span class="label"><a href="${get_online_help_info(online_help_token)['doc_url']}" title="${_('Contextual Online Help')}" target="_blank">${_("Help")}</a></span></h3> </li> <li class="nav-item nav-account-user"> - <h3 class="title"><span class="label"><span class="label-prefix sr">${_("Currently signed in as:")}</span><span class="account-username" title="${ user.username }">${ user.username }</span></span> <span class="icon fa fa-caret-down ui-toggle-dd" aria-hidden="true"></span></h3> - - <div class="wrapper wrapper-nav-sub"> - <div class="nav-sub"> - <ul> - <li class="nav-item nav-account-dashboard"> - <a href="/">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</a> - </li> - <li class="nav-item nav-account-signout"> - <a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a> - </li> - </ul> - </div> - </div> + <%include file="user_dropdown.html" args="online_help_token=online_help_token" /> </li> </ol> </nav> - % else: + % else: <nav class="nav-not-signedin nav-pitch" aria-label="${_('Account')}"> - <h2 class="sr">${_("Account Navigation")}</h2> + <h2 class="sr-only">${_("Account Navigation")}</h2> <ol> <li class="nav-item nav-not-signedin-help"> <a href="${get_online_help_info(online_help_token)['doc_url']}" title="${_('Contextual Online Help')}" target="_blank">${_("Help")}</a> @@ -254,7 +243,7 @@ </li> </ol> </nav> - % endif + % endif </div> </header> </div> diff --git a/cms/templates/widgets/sock.html b/cms/templates/widgets/sock.html index 5c3cfc5a1b6..b271376a9ca 100644 --- a/cms/templates/widgets/sock.html +++ b/cms/templates/widgets/sock.html @@ -1,8 +1,8 @@ +<%page expression_filter="h" args="online_help_token" /> <%! from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse %> -<%page args="online_help_token"/> <div class="wrapper-sock wrapper"> <ul class="list-actions list-cta"> <li class="action-item"> @@ -15,8 +15,7 @@ from django.core.urlresolvers import reverse <div class="wrapper-inner wrapper"> <section class="sock" id="sock" aria-labelledby="sock-heading"> - <h2 id="sock-heading" class="title sr">${_("{studio_name} Documentation").format(studio_name=settings.STUDIO_NAME)}</h2> - + <h2 id="sock-heading" class="title sr-only">${_("{studio_name} Documentation").format(studio_name=settings.STUDIO_NAME)}</h2> <div class="support"> <%! from django.conf import settings diff --git a/cms/templates/widgets/user_dropdown.html b/cms/templates/widgets/user_dropdown.html new file mode 100644 index 00000000000..bebc2e21d0c --- /dev/null +++ b/cms/templates/widgets/user_dropdown.html @@ -0,0 +1,54 @@ +<%page expression_filter="h"/> +<%namespace name='static' file='../static_content.html'/> +<%! + from django.conf import settings + from django.core.urlresolvers import reverse + from django.utils.translation import ugettext as _ +%> + +% if uses_pattern_library: + <div class="wrapper-user-menu dropdown-menu-container logged-in js-header-user-menu"> + <h3 class="title menu-title"> + <span class="sr-only">${_("Currently signed in as:")}</span> + <span class="account-username" title="${ user.username }">${ user.username }</span> + </h3> + <button type="button" class="menu-button button-more has-dropdown js-dropdown-button default-icon" aria-haspopup="true" aria-expanded="false" aria-controls="${_("Usermenu")}"> + <span class="icon-fallback icon-fallback-img"> + <span class="icon icon-angle-down" aria-hidden="true"></span> + <span class="sr-only">${_("Usermenu dropdown")}</span> + </span> + </button> + <ul class="dropdown-menu list-divided is-hidden" id="${_("Usermenu")}" tabindex="-1"> + <%block name="navigation_dropdown_menu_links" > + <li class="dropdown-item item has-block-link"> + <a href="/">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</a> + </li> + </%block> + <li class="dropdown-item item has-block-link"> + <a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a> + </li> + </ul> + </div> + +% else: + <h3 class="title"> + <span class="label"> + <span class="label-prefix sr-only">${_("Currently signed in as:")}</span> + <span class="account-username" title="${ user.username }">${ user.username }</span> + </span> + <span class="icon fa fa-caret-down ui-toggle-dd" aria-hidden="true"></span> + </h3> + + <div class="wrapper wrapper-nav-sub"> + <div class="nav-sub"> + <ul> + <li class="nav-item nav-account-dashboard"> + <a href="/">${_("{studio_name} Home").format(studio_name=settings.STUDIO_SHORT_NAME)}</a> + </li> + <li class="nav-item nav-account-signout"> + <a class="action action-signout" href="${reverse('logout')}">${_("Sign Out")}</a> + </li> + </ul> + </div> + </div> +% endif \ No newline at end of file diff --git a/common/test/acceptance/fixtures/programs.py b/common/test/acceptance/fixtures/programs.py index b35f6b3561f..baaea091803 100644 --- a/common/test/acceptance/fixtures/programs.py +++ b/common/test/acceptance/fixtures/programs.py @@ -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, diff --git a/openedx/core/djangoapps/programs/models.py b/openedx/core/djangoapps/programs/models.py index affc4cdeee9..3f54d5694f5 100644 --- a/openedx/core/djangoapps/programs/models.py +++ b/openedx/core/djangoapps/programs/models.py @@ -1,5 +1,4 @@ """Models providing Programs support for the LMS and Studio.""" -from collections import namedtuple from urlparse import urljoin from django.utils.translation import ugettext_lazy as _ @@ -8,9 +7,6 @@ from django.db import models from config_models.models import ConfigurationModel -AuthoringAppConfig = namedtuple('AuthoringAppConfig', ['js_url', 'css_url']) - - class ProgramsApiConfig(ConfigurationModel): """ Manages configuration for connecting to the Programs service and using its @@ -25,6 +21,7 @@ class ProgramsApiConfig(ConfigurationModel): internal_service_url = models.URLField(verbose_name=_("Internal Service URL")) public_service_url = models.URLField(verbose_name=_("Public Service URL")) + # TODO: The property below is obsolete. Delete at the earliest safe moment. See ECOM-4995 authoring_app_js_path = models.CharField( verbose_name=_("Path to authoring app's JS"), max_length=255, @@ -33,6 +30,8 @@ class ProgramsApiConfig(ConfigurationModel): "This value is required in order to enable the Studio authoring interface." ) ) + + # TODO: The property below is obsolete. Delete at the earliest safe moment. See ECOM-4995 authoring_app_css_path = models.CharField( verbose_name=_("Path to authoring app's CSS"), max_length=255, @@ -103,17 +102,6 @@ class ProgramsApiConfig(ConfigurationModel): """ return urljoin(self.public_service_url, '/api/v{}/'.format(self.api_version_number)) - @property - def authoring_app_config(self): - """ - Returns a named tuple containing information required for working with the Programs - authoring app, a Backbone app hosted by the Programs service. - """ - js_url = urljoin(self.public_service_url, self.authoring_app_js_path) - css_url = urljoin(self.public_service_url, self.authoring_app_css_path) - - return AuthoringAppConfig(js_url=js_url, css_url=css_url) - @property def is_cache_enabled(self): """Whether responses from the Programs API will be cached.""" @@ -133,12 +121,7 @@ class ProgramsApiConfig(ConfigurationModel): Indicates whether Studio functionality related to Programs should be enabled or not. """ - return ( - self.enabled and - self.enable_studio_tab and - bool(self.authoring_app_js_path) and - bool(self.authoring_app_css_path) - ) + return self.enabled and self.enable_studio_tab @property def is_certification_enabled(self): diff --git a/openedx/core/djangoapps/programs/tests/mixins.py b/openedx/core/djangoapps/programs/tests/mixins.py index 2a9c4360e88..3b6bf79d7f2 100644 --- a/openedx/core/djangoapps/programs/tests/mixins.py +++ b/openedx/core/djangoapps/programs/tests/mixins.py @@ -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, diff --git a/openedx/core/djangoapps/programs/tests/test_models.py b/openedx/core/djangoapps/programs/tests/test_models.py index 8b2118e6627..71386950c76 100644 --- a/openedx/core/djangoapps/programs/tests/test_models.py +++ b/openedx/core/djangoapps/programs/tests/test_models.py @@ -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) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 48b51dfdcbf..cb8d6676abf 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -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__) diff --git a/package.json b/package.json index b8a766e9d6e..3ab10347425 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pavelib/assets.py b/pavelib/assets.py index 269b6b0a9a4..c1320eb698a 100644 --- a/pavelib/assets.py +++ b/pavelib/assets.py @@ -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 diff --git a/themes/edx.org/cms/templates/widgets/sock.html b/themes/edx.org/cms/templates/widgets/sock.html index 44f059a569d..571d90320c9 100644 --- a/themes/edx.org/cms/templates/widgets/sock.html +++ b/themes/edx.org/cms/templates/widgets/sock.html @@ -1,8 +1,8 @@ +<%page expression_filter="h" args="online_help_token"/> <%! from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse %> -<%page args="online_help_token"/> <div class="wrapper-sock wrapper"> <ul class="list-actions list-cta"> <li class="action-item"> @@ -16,7 +16,7 @@ from django.core.urlresolvers import reverse <div class="wrapper-inner wrapper"> <section class="sock" id="sock"> <header> - <h2 class="title sr">${_("{studio_name} Documentation").format(studio_name=settings.STUDIO_NAME)}</h2> + <h2 class="title sr-only">${_("{studio_name} Documentation").format(studio_name=settings.STUDIO_NAME)}</h2> </header> <div class="support"> -- GitLab