diff --git a/.gitignore b/.gitignore index fbf12d0f37..ef5145850a 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ cover_html/ reports/ jscover.log jscover.log.* +.tddium* ### Installation artifacts *.egg-info diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b6390d58de..e2b7eb2983 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Common: Add configurable reset button to units + LMS: Support adding cohorts from the instructor dashboard. TNL-162 LMS: Support adding students to a cohort via the instructor dashboard. TNL-163 diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index c49535b3de..7ad4675f6f 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -13,6 +13,7 @@ MAXIMUM_ATTEMPTS = "Maximum Attempts" PROBLEM_WEIGHT = "Problem Weight" RANDOMIZATION = 'Randomization' SHOW_ANSWER = "Show Answer" +SHOW_RESET_BUTTON = "Show Reset Button" TIMER_BETWEEN_ATTEMPTS = "Timer Between Attempts" MATLAB_API_KEY = "Matlab API key" @@ -102,6 +103,7 @@ def i_see_advanced_settings_with_values(step): [PROBLEM_WEIGHT, "", False], [RANDOMIZATION, "Never", False], [SHOW_ANSWER, "Finished", False], + [SHOW_RESET_BUTTON, "False", False], [TIMER_BETWEEN_ATTEMPTS, "0", False], ]) diff --git a/cms/envs/common.py b/cms/envs/common.py index 10c285045d..93c0fb4a74 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -325,8 +325,7 @@ MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' EMBARGO_SITE_REDIRECT_URL = None ############################### Pipeline ####################################### - -STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage' +STATICFILES_STORAGE = 'cms.lib.django_require.staticstorage.OptimizedCachedRequireJsStorage' from rooted_paths import rooted_glob @@ -457,6 +456,34 @@ STATICFILES_IGNORE_PATTERNS = ( PIPELINE_YUI_BINARY = 'yui-compressor' +################################# DJANGO-REQUIRE ############################### + +# The baseUrl to pass to the r.js optimizer, relative to STATIC_ROOT. +REQUIRE_BASE_URL = "./" + +# The name of a build profile to use for your project, relative to REQUIRE_BASE_URL. +# A sensible value would be 'app.build.js'. Leave blank to use the built-in default build profile. +# Set to False to disable running the default profile (e.g. if only using it to build Standalone +# Modules) +REQUIRE_BUILD_PROFILE = "build.js" + +# The name of the require.js script used by your project, relative to REQUIRE_BASE_URL. +REQUIRE_JS = "js/vendor/require.js" + +# A dictionary of standalone modules to build with almond.js. +REQUIRE_STANDALONE_MODULES = {} + +# Whether to run django-require in debug mode. +REQUIRE_DEBUG = False + +# A tuple of files to exclude from the compilation result of r.js. +REQUIRE_EXCLUDE = ("build.txt",) + +# The execution environment in which to run r.js: auto, node or rhino. +# auto will autodetect the environment and make use of node if available and rhino if not. +# It can also be a path to a custom class that subclasses require.environments.Environment and defines some "args" function that returns a list with the command arguments to execute. +REQUIRE_ENVIRONMENT = "node" + ################################# CELERY ###################################### # Message configuration @@ -563,6 +590,7 @@ INSTALLED_APPS = ( 'pipeline', 'staticfiles', 'static_replace', + 'require', # comment common 'django_comment_common', diff --git a/cms/lib/django_require/__init__.py b/cms/lib/django_require/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cms/lib/django_require/staticstorage.py b/cms/lib/django_require/staticstorage.py new file mode 100644 index 0000000000..4170473acb --- /dev/null +++ b/cms/lib/django_require/staticstorage.py @@ -0,0 +1,13 @@ +""" +:class:`~django_require.staticstorage.OptimizedCachedRequireJsStorage` +""" + +from pipeline.storage import PipelineCachedStorage +from require.storage import OptimizedFilesMixin + + +class OptimizedCachedRequireJsStorage(OptimizedFilesMixin, PipelineCachedStorage): + """ + Custom storage backend that is used by Django-require. + """ + pass diff --git a/cms/static/build.js b/cms/static/build.js new file mode 100644 index 0000000000..d211b2a0e6 --- /dev/null +++ b/cms/static/build.js @@ -0,0 +1,155 @@ +(function () { + 'use strict'; + var getModule = function (moduleName, excludeCommonDeps) { + var module = { + name: moduleName + }; + + if (excludeCommonDeps) { + module.exclude = ['js/factories/common_deps']; + } + + return module; + }; + + var getModulesList = function (modules) { + var result = [getModule('js/factories/common_deps')]; + return result.concat(modules.map(function (moduleName) { + return getModule(moduleName, true); + })); + }; + + return { + /** + * List the modules that will be optimized. All their immediate and deep + * dependencies will be included in the module's file when the build is + * done. + */ + modules: getModulesList([ + 'js/factories/asset_index', + 'js/factories/base', + 'js/factories/checklists', + 'js/factories/container', + 'js/factories/course', + 'js/factories/course_create_rerun', + 'js/factories/course_info', + 'js/factories/edit_tabs', + 'js/factories/export', + 'js/factories/group_configurations', + 'js/factories/import', + 'js/factories/index', + 'js/factories/login', + 'js/factories/manage_users', + 'js/factories/outline', + 'js/factories/register', + 'js/factories/settings', + 'js/factories/settings_advanced', + 'js/factories/settings_graders', + 'js/factories/textbooks' + ]), + /** + * By default all the configuration for optimization happens from the command + * line or by properties in the config file, and configuration that was + * passed to requirejs as part of the app's runtime "main" JS file is *not* + * considered. However, if you prefer the "main" JS file configuration + * to be read for the build so that you do not have to duplicate the values + * in a separate configuration, set this property to the location of that + * main JS file. The first requirejs({}), require({}), requirejs.config({}), + * or require.config({}) call found in that file will be used. + * As of 2.1.10, mainConfigFile can be an array of values, with the last + * value's config take precedence over previous values in the array. + */ + mainConfigFile: 'require-config.js', + /** + * Set paths for modules. If relative paths, set relative to baseUrl above. + * If a special value of "empty:" is used for the path value, then that + * acts like mapping the path to an empty file. It allows the optimizer to + * resolve the dependency to path, but then does not include it in the output. + * Useful to map module names that are to resources on a CDN or other + * http: URL when running in the browser and during an optimization that + * file should be skipped because it has no dependencies. + */ + paths: { + 'gettext': 'empty:', + 'xmodule': 'empty:', + 'mathjax': 'empty:', + 'tender': 'empty:', + 'youtube': 'empty:' + }, + /** + * If shim config is used in the app during runtime, duplicate the config + * here. Necessary if shim config is used, so that the shim's dependencies + * are included in the build. Using "mainConfigFile" is a better way to + * pass this information though, so that it is only listed in one place. + * However, if mainConfigFile is not an option, the shim config can be + * inlined in the build config. + */ + shim: { + 'xmodule': { + deps: [ + 'jquery', 'underscore', 'mathjax', 'codemirror', 'tinymce', + 'jquery.tinymce', 'jquery.qtip', 'jquery.scrollTo', 'jquery.flot', + 'jquery.cookie', 'utility' + ] + } + }, + /** + * Introduced in 2.1.2: If using "dir" for an output directory, normally the + * optimize setting is used to optimize the build bundles (the "modules" + * section of the config) and any other JS file in the directory. However, if + * the non-build bundle JS files will not be loaded after a build, you can + * skip the optimization of those files, to speed up builds. Set this value + * to true if you want to skip optimizing those other non-build bundle JS + * files. + */ + skipDirOptimize: true, + /** + * When the optimizer copies files from the source location to the + * destination directory, it will skip directories and files that start + * with a ".". If you want to copy .directories or certain .files, for + * instance if you keep some packages in a .packages directory, or copy + * over .htaccess files, you can set this to null. If you want to change + * the exclusion rules, change it to a different regexp. If the regexp + * matches, it means the directory will be excluded. This used to be + * called dirExclusionRegExp before the 1.0.2 release. + * As of 1.0.3, this value can also be a string that is converted to a + * RegExp via new RegExp(). + */ + fileExclusionRegExp: /^\.|spec/, + /** + * Allow CSS optimizations. Allowed values: + * - "standard": @import inlining and removal of comments, unnecessary + * whitespace and line returns. + * Removing line returns may have problems in IE, depending on the type + * of CSS. + * - "standard.keepLines": like "standard" but keeps line returns. + * - "none": skip CSS optimizations. + * - "standard.keepComments": keeps the file comments, but removes line + * returns. (r.js 1.0.8+) + * - "standard.keepComments.keepLines": keeps the file comments and line + * returns. (r.js 1.0.8+) + * - "standard.keepWhitespace": like "standard" but keeps unnecessary whitespace. + */ + optimizeCss: 'none', + /** + * How to optimize all the JS files in the build output directory. + * Right now only the following values are supported: + * - "uglify": Uses UglifyJS to minify the code. + * - "uglify2": Uses UglifyJS2. + * - "closure": Uses Google's Closure Compiler in simple optimization + * mode to minify the code. Only available if REQUIRE_ENVIRONMENT is "rhino" (the default). + * - "none": No minification will be done. + */ + optimize: 'uglify2', + /** + * Sets the logging level. It is a number: + * TRACE: 0, + * INFO: 1, + * WARN: 2, + * ERROR: 3, + * SILENT: 4 + * Default is 0. + */ + logLevel: 4 + }; +} ()) diff --git a/cms/static/js/factories/asset_index.js b/cms/static/js/factories/asset_index.js new file mode 100644 index 0000000000..f9e80c5a22 --- /dev/null +++ b/cms/static/js/factories/asset_index.js @@ -0,0 +1,13 @@ +define([ + 'jquery', 'js/collections/asset', 'js/views/assets', 'jquery.fileupload' +], function($, AssetCollection, AssetsView) { + 'use strict'; + return function (assetCallbackUrl) { + var assets = new AssetCollection(), + assetsView; + + assets.url = assetCallbackUrl; + assetsView = new AssetsView({collection: assets, el: $('.assets-wrapper')}); + assetsView.render(); + }; +}); diff --git a/cms/static/js/factories/base.js b/cms/static/js/factories/base.js new file mode 100644 index 0000000000..49671bb8ec --- /dev/null +++ b/cms/static/js/factories/base.js @@ -0,0 +1,2 @@ +define(['js/base', 'coffee/src/main', 'coffee/src/logger', 'datepair', 'accessibility', +'ieshim', 'tooltip_manager']); diff --git a/cms/static/js/factories/checklists.js b/cms/static/js/factories/checklists.js new file mode 100644 index 0000000000..d0cf812683 --- /dev/null +++ b/cms/static/js/factories/checklists.js @@ -0,0 +1,16 @@ +define([ + 'jquery', 'js/collections/checklist', 'js/views/checklist' +], function($, ChecklistCollection, ChecklistView) { + 'use strict'; + return function (handlerUrl) { + var checklistCollection = new ChecklistCollection(), + editor; + + checklistCollection.url = handlerUrl; + editor = new ChecklistView({ + el: $('.course-checklists'), + collection: checklistCollection + }); + checklistCollection.fetch({reset: true}); + }; +}); diff --git a/cms/static/js/factories/common_deps.js b/cms/static/js/factories/common_deps.js new file mode 100644 index 0000000000..1e935d6821 --- /dev/null +++ b/cms/static/js/factories/common_deps.js @@ -0,0 +1 @@ +define(['domReady!', 'jquery', 'backbone', 'underscore', 'gettext']); diff --git a/cms/static/js/factories/container.js b/cms/static/js/factories/container.js new file mode 100644 index 0000000000..93cdeb8fd9 --- /dev/null +++ b/cms/static/js/factories/container.js @@ -0,0 +1,23 @@ +define([ + 'jquery', 'js/models/xblock_info', 'js/views/pages/container', + 'js/collections/component_template', 'xmodule', 'coffee/src/main', + 'xblock/cms.runtime.v1' +], +function($, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) { + 'use strict'; + return function (componentTemplates, XBlockInfoJson, action, isUnitPage) { + var templates = new ComponentTemplates(componentTemplates, {parse: true}), + mainXBlockInfo = new XBlockInfo(XBlockInfoJson, {parse: true}); + + xmoduleLoader.done(function () { + var view = new ContainerPage({ + el: $('#content'), + model: mainXBlockInfo, + action: action, + templates: templates, + isUnitPage: isUnitPage + }); + view.render(); + }); + }; +}); diff --git a/cms/static/js/factories/course.js b/cms/static/js/factories/course.js new file mode 100644 index 0000000000..b5b5f8ad84 --- /dev/null +++ b/cms/static/js/factories/course.js @@ -0,0 +1,6 @@ +define(['js/models/course'], function(Course) { + 'use strict'; + return function (courseInfo) { + window.course = new Course(courseInfo); + } +}); diff --git a/cms/static/js/factories/course_create_rerun.js b/cms/static/js/factories/course_create_rerun.js new file mode 100644 index 0000000000..98afc1d6e5 --- /dev/null +++ b/cms/static/js/factories/course_create_rerun.js @@ -0,0 +1,4 @@ +define(['jquery', 'jquery.form', 'js/views/course_rerun'], function ($) { + 'use strict'; + return function () {}; +}); diff --git a/cms/static/js/factories/course_info.js b/cms/static/js/factories/course_info.js new file mode 100644 index 0000000000..53f4f0b194 --- /dev/null +++ b/cms/static/js/factories/course_info.js @@ -0,0 +1,25 @@ +define([ + 'jquery', 'js/collections/course_update', 'js/models/module_info', + 'js/models/course_info', 'js/views/course_info_edit' +], function($, CourseUpdateCollection, ModuleInfoModel, CourseInfoModel, CourseInfoEditView) { + 'use strict'; + return function (updatesUrl, handoutsLocator, baseAssetUrl) { + var course_updates = new CourseUpdateCollection(), + course_handouts, editor; + + course_updates.url = updatesUrl; + course_updates.fetch({reset: true}); + course_handouts = new ModuleInfoModel({ + id: handoutsLocator + }); + editor = new CourseInfoEditView({ + el: $('.main-wrapper'), + model : new CourseInfoModel({ + updates : course_updates, + base_asset_url : baseAssetUrl, + handouts : course_handouts + }) + }); + editor.render(); + }; +}); diff --git a/cms/static/js/factories/edit_tabs.js b/cms/static/js/factories/edit_tabs.js new file mode 100644 index 0000000000..34ad04d12a --- /dev/null +++ b/cms/static/js/factories/edit_tabs.js @@ -0,0 +1,20 @@ +define([ + 'js/models/explicit_url', 'coffee/src/views/tabs', 'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1' +], function (TabsModel, TabsEditView, xmoduleLoader) { + 'use strict'; + return function (courseLocation, explicitUrl) { + xmoduleLoader.done(function () { + var model = new TabsModel({ + id: courseLocation, + explicit_url: explicitUrl + }), + editView; + + editView = new TabsEditView({ + el: $('.tab-list'), + model: model, + mast: $('.wrapper-mast') + }); + }); + }; +}); diff --git a/cms/static/js/factories/export.js b/cms/static/js/factories/export.js new file mode 100644 index 0000000000..295fd406d5 --- /dev/null +++ b/cms/static/js/factories/export.js @@ -0,0 +1,57 @@ +define(['gettext', 'js/views/feedback_prompt'], function(gettext, PromptView) { + 'use strict'; + return function (hasUnit, editUnitUrl, courseHomeUrl, errMsg) { + var dialog; + if(hasUnit) { + dialog = new PromptView({ + title: gettext('There has been an error while exporting.'), + message: gettext('There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages.'), + intent: 'error', + actions: { + primary: { + text: gettext('Correct failed component'), + click: function(view) { + view.hide(); + document.location = editUnitUrl; + } + }, + secondary: { + text: gettext('Return to Export'), + click: function(view) { + view.hide(); + } + } + } + }); + } else { + var msg = '
' + gettext('There has been a failure to export your course to XML. Unfortunately, we do not have specific enough information to assist you in identifying the failed component. It is recommended that you inspect your courseware to identify any components in error and try again.') + '
' + gettext('The raw error message is:') + '
' + errMsg; + dialog = new PromptView({ + title: gettext('There has been an error with your export.'), + message: msg, + intent: 'error', + actions: { + primary: { + text: gettext('Yes, take me to the main course page'), + click: function(view) { + view.hide(); + document.location = courseHomeUrl; + } + }, + secondary: { + text: gettext('Cancel'), + click: function(view) { + view.hide(); + } + } + } + }); + } + + // The CSS animation for the dialog relies on the 'js' class + // being on the body. This happens after this JavaScript is executed, + // causing a 'bouncing' of the dialog after it is initially shown. + // As a workaround, add this class first. + $('body').addClass('js'); + dialog.show(); + }; +}); diff --git a/cms/static/js/factories/group_configurations.js b/cms/static/js/factories/group_configurations.js new file mode 100644 index 0000000000..559c4344ee --- /dev/null +++ b/cms/static/js/factories/group_configurations.js @@ -0,0 +1,16 @@ +define([ + 'js/collections/group_configuration', 'js/views/pages/group_configurations' +], function(GroupConfigurationCollection, GroupConfigurationsPage) { + 'use strict'; + return function (configurations, groupConfigurationUrl, courseOutlineUrl) { + var collection = new GroupConfigurationCollection(configurations, { parse: true }), + configurationsPage; + + collection.url = groupConfigurationUrl; + collection.outlineUrl = courseOutlineUrl; + configurationsPage = new GroupConfigurationsPage({ + el: $('#content'), + collection: collection + }).render(); + }; +}); diff --git a/cms/static/js/factories/import.js b/cms/static/js/factories/import.js new file mode 100644 index 0000000000..2577844e42 --- /dev/null +++ b/cms/static/js/factories/import.js @@ -0,0 +1,107 @@ +define([ + 'js/views/import', 'jquery', 'gettext', 'jquery.fileupload', 'jquery.cookie' +], function(CourseImport, $, gettext) { + 'use strict'; + return function (feedbackUrl) { + var bar = $('.progress-bar'), + fill = $('.progress-fill'), + submitBtn = $('.submit-button'), + chooseBtn = $('.choose-file-button'), + defaults = [ + gettext('There was an error during the upload process.') + '\n', + gettext('There was an error while unpacking the file.') + '\n', + gettext('There was an error while verifying the file you submitted.') + '\n', + gettext('There was an error while importing the new course to our database.') + '\n' + ], + // Display the status of last file upload on page load + lastFileUpload = $.cookie('lastfileupload'), + file; + + if (lastFileUpload){ + CourseImport.getAndStartUploadFeedback(feedbackUrl.replace('fillerName', lastFileUpload), lastFileUpload); + } + + $('#fileupload').fileupload({ + dataType: 'json', + type: 'POST', + maxChunkSize: 20 * 1000000, // 20 MB + autoUpload: false, + add: function(e, data) { + CourseImport.clearImportDisplay(); + submitBtn.unbind('click'); + file = data.files[0]; + if (file.name.match(/tar\.gz$/)) { + submitBtn.click(function(event){ + event.preventDefault(); + $.cookie('lastfileupload', file.name); + submitBtn.hide(); + CourseImport.startUploadFeedback(); + data.submit().complete(function(result, textStatus, xhr) { + window.onbeforeunload = null; + if (xhr.status != 200) { + var serverMsg, errMsg, stage; + try{ + serverMsg = $.parseJSON(result.responseText); + } catch (e) { + return; + } + errMsg = serverMsg.hasOwnProperty('ErrMsg') ? serverMsg.ErrMsg : '' ; + if (serverMsg.hasOwnProperty('Stage')) { + stage = Math.abs(serverMsg.Stage); + CourseImport.stageError(stage, defaults[stage] + errMsg); + } + else { + alert(gettext('Your import has failed.') + '\n\n' + errMsg); + } + chooseBtn.html(gettext('Choose new file')).show(); + bar.hide(); + } + CourseImport.stopGetStatus = true; + chooseBtn.html(gettext('Choose new file')).show(); + bar.hide(); + }); + }); + } else { + data.files = []; + } + }, + + progressall: function(e, data){ + var percentInt = data.loaded / data.total * 100, + percentVal = parseInt(percentInt, 10) + '%', + doneAt; + // Firefox makes ProgressEvent.loaded equal ProgressEvent.total only + // after receiving a response from the server (see Mozilla bug 637002), + // so for Firefox we jump the gun a little. + if (navigator.userAgent.toLowerCase().indexOf('firefox') > -1) { + doneAt = 95; + } else { + doneAt = 99; + } + if (percentInt >= doneAt) { + bar.hide(); + // Start feedback with delay so that current stage of import properly updates in session + setTimeout( + function () { CourseImport.startServerFeedback(feedbackUrl.replace('fillerName', file.name));}, + 3000 + ); + } else { + bar.show(); + fill.width(percentVal).html(percentVal); + } + }, + done: function(event, data){ + bar.hide(); + window.onbeforeunload = null; + CourseImport.displayFinishedImport(); + }, + start: function(event) { + window.onbeforeunload = function() { + return gettext('Your import is in progress; navigating away will abort it.'); + }; + }, + sequentialUploads: true, + notifyOnError: false + }); + }; +}); diff --git a/cms/static/js/factories/index.js b/cms/static/js/factories/index.js new file mode 100644 index 0000000000..15ee2ec501 --- /dev/null +++ b/cms/static/js/factories/index.js @@ -0,0 +1,40 @@ +define(['jquery.form', 'js/index'], function() { + 'use strict'; + return function () { + // showing/hiding creation rights UI + $('.show-creationrights').click(function(e) { + e.preventDefault(); + $(this) + .closest('.wrapper-creationrights') + .toggleClass('is-shown') + .find('.ui-toggle-control') + .toggleClass('current'); + }); + + var reloadPage = function () { + location.reload(); + }; + + var showError = function () { + $('#request-coursecreator-submit') + .toggleClass('has-error') + .find('.label') + .text('Sorry, there was error with your request'); + $('#request-coursecreator-submit') + .find('.icon-cog') + .toggleClass('icon-spin'); + }; + + $('#request-coursecreator').ajaxForm({ + error: showError, + success: reloadPage + }); + + $('#request-coursecreator-submit').click(function(event){ + $(this) + .toggleClass('is-disabled is-submitting') + .find('.label') + .text('Submitting Your Request'); + }); + }; +}); diff --git a/cms/static/js/factories/login.js b/cms/static/js/factories/login.js new file mode 100644 index 0000000000..5115406044 --- /dev/null +++ b/cms/static/js/factories/login.js @@ -0,0 +1,43 @@ +define(['jquery.cookie', 'utility'], function() { + 'use strict'; + return function (homepageURL) { + function postJSON(url, data, callback) { + $.ajax({ + type:'POST', + url: url, + dataType: 'json', + data: data, + success: callback, + headers : {'X-CSRFToken':$.cookie('csrftoken')} + }); + } + + $('form#login_form').submit(function(event) { + event.preventDefault(); + var submit_data = $('#login_form').serialize(); + + postJSON('/login_post', submit_data, function(json) { + if(json.success) { + var next = /next=([^&]*)/g.exec(decodeURIComponent(window.location.search)); + if (next && next.length > 1 && !isExternal(next[1])) { + location.href = next[1]; + } else { + location.href = homepageURL; + } + } else if($('#login_error').length === 0) { + $('#login_form').prepend( + '' + ); + $('#login_error').addClass('is-shown'); + } else { + $('#login_error') + .stop() + .addClass('is-shown') + .html(json.value); + } + }); + }); + }; +}); diff --git a/cms/static/js/factories/manage_users.js b/cms/static/js/factories/manage_users.js new file mode 100644 index 0000000000..42272a859d --- /dev/null +++ b/cms/static/js/factories/manage_users.js @@ -0,0 +1,203 @@ +define(['jquery', 'underscore', 'gettext', 'js/views/feedback_prompt'], function($, _, gettext, PromptView) { + 'use strict'; + return function (staffEmails, tplUserURL) { + var unknownErrorMessage = gettext('Unknown'), + $createUserForm = $('#create-user-form'), + $createUserFormWrapper = $createUserForm.closest('.wrapper-create-user'), + $cancelButton; + + $createUserForm.bind('submit', function(event) { + event.preventDefault(); + var email = $('#user-email-input').val().trim(), + url, msg; + + if(!email) { + msg = new PromptView.Error({ + title: gettext('A valid email address is required'), + message: gettext('You must enter a valid email address in order to add a new team member'), + actions: { + primary: { + text: gettext('Return and add email address'), + click: function(view) { + view.hide(); + $('#user-email-input').focus(); + } + } + } + }); + msg.show(); + } + + if(_.contains(staffEmails, email)) { + msg = new PromptView.Warning({ + title: gettext('Already a course team member'), + message: _.template( + gettext("{email} is already on the “{course}” team. If you're trying to add a new member, please double-check the email address you provided."), { + email: email, + course: course.escape('name') + }, {interpolate: /\{(.+?)\}/g} + ), + actions: { + primary: { + text: gettext('Return to team listing'), + click: function(view) { + view.hide(); + $('#user-email-input').focus(); + } + } + } + }); + msg.show(); + } + + url = tplUserURL.replace('@@EMAIL@@', $('#user-email-input').val().trim()); + $.ajax({ + url: url, + type: 'POST', + dataType: 'json', + contentType: 'application/json', + notifyOnError: false, + data: JSON.stringify({role: 'staff'}), + success: function(data) {location.reload();}, + error: function(jqXHR, textStatus, errorThrown) { + var message, prompt; + try { + message = JSON.parse(jqXHR.responseText).error || unknownErrorMessage; + } catch (e) { + message = unknownErrorMessage; + } + prompt = new PromptView.Error({ + title: gettext('Error adding user'), + message: message, + actions: { + primary: { + text: gettext('OK'), + click: function(view) { + view.hide(); + $('#user-email-input').focus(); + } + } + } + }); + prompt.show(); + } + }); + }); + + $cancelButton = $createUserForm.find('.action-cancel'); + $cancelButton.bind('click', function(event) { + event.preventDefault(); + $('.create-user-button').toggleClass('is-disabled'); + $createUserFormWrapper.toggleClass('is-shown'); + $('#user-email-input').val(''); + }); + + $('.create-user-button').bind('click', function(event) { + event.preventDefault(); + $('.create-user-button').toggleClass('is-disabled'); + $createUserFormWrapper.toggleClass('is-shown'); + $createUserForm.find('#user-email-input').focus(); + }); + + $('body').bind('keyup', function(event) { + if(event.which == 27) { + $cancelButton.click(); + } + }); + + $('.remove-user').click(function() { + var email = $(this).data('id'), + msg = new PromptView.Warning({ + title: gettext('Are you sure?'), + message: _.template(gettext('Are you sure you want to delete {email} from the course team for “{course}”?'), {email: email, course: course.get('name')}, {interpolate: /\{(.+?)\}/g}), + actions: { + primary: { + text: gettext('Delete'), + click: function(view) { + var url = tplUserURL.replace('@@EMAIL@@', email); + view.hide(); + $.ajax({ + url: url, + type: 'DELETE', + dataType: 'json', + contentType: 'application/json', + notifyOnError: false, + success: function(data) {location.reload();}, + error: function(jqXHR, textStatus, errorThrown) { + var message; + try { + message = JSON.parse(jqXHR.responseText).error || unknownErrorMessage; + } catch (e) { + message = unknownErrorMessage; + } + var prompt = new PromptView.Error({ + title: gettext('Error removing user'), + message: message, + actions: { + primary: { + text: gettext('OK'), + click: function(view) { + view.hide(); + } + } + } + }); + prompt.show(); + } + }); + } + }, + secondary: { + text: gettext('Cancel'), + click: function(view) { + view.hide(); + } + } + } + }); + msg.show(); + }); + + $('.toggle-admin-role').click(function(event) { + event.preventDefault(); + var type, url, role; + if($(this).hasClass('add-admin-role')) { + role = 'instructor'; + } else { + role = 'staff'; + } + + url = $(this).closest('li[data-url]').data('url'); + $.ajax({ + url: url, + type: 'POST', + dataType: 'json', + contentType: 'application/json', + notifyOnError: false, + data: JSON.stringify({role: role}), + success: function(data) {location.reload();}, + error: function(jqXHR, textStatus, errorThrown) { + var message, prompt; + try { + message = JSON.parse(jqXHR.responseText).error || unknownErrorMessage; + } catch (e) { + message = unknownErrorMessage; + } + prompt = new PromptView.Error({ + title: gettext("There was an error changing the user's role"), + message: message, + actions: { + primary: { + text: gettext('Try Again'), + click: function(view) { + view.hide(); + } + } + } + }); + prompt.show(); + } + }); + }); + }; +}); diff --git a/cms/static/js/factories/outline.js b/cms/static/js/factories/outline.js new file mode 100644 index 0000000000..a0b6cb25e5 --- /dev/null +++ b/cms/static/js/factories/outline.js @@ -0,0 +1,14 @@ +define([ + 'js/views/pages/course_outline', 'js/models/xblock_outline_info' +], function(CourseOutlinePage, XBlockOutlineInfo) { + 'use strict'; + return function (XBlockOutlineInfoJson, initialStateJson) { + var courseXBlock = new XBlockOutlineInfo(XBlockOutlineInfoJson, {parse: true}), + view = new CourseOutlinePage({ + el: $('#content'), + model: courseXBlock, + initialState: initialStateJson + }); + view.render(); + }; +}); diff --git a/cms/static/js/factories/register.js b/cms/static/js/factories/register.js new file mode 100644 index 0000000000..5f0fe5aaf6 --- /dev/null +++ b/cms/static/js/factories/register.js @@ -0,0 +1,33 @@ +define(['jquery', 'jquery.cookie'], function($) { + 'use strict'; + return function () { + $('form :input') + .focus(function() { + $('label[for="' + this.id + '"]').addClass('is-focused'); + }) + .blur(function() { + $('label').removeClass('is-focused'); + }); + + $('form#register_form').submit(function(event) { + event.preventDefault(); + var submit_data = $('#register_form').serialize(); + + $.ajax({ + url: '/create_account', + type: 'POST', + dataType: 'json', + headers: {'X-CSRFToken': $.cookie('csrftoken')}, + notifyOnError: false, + data: submit_data, + success: function(json) { + location.href = '/course/'; + }, + error: function(jqXHR, textStatus, errorThrown) { + var json = $.parseJSON(jqXHR.responseText); + $('#register_error').html(json.value).stop().addClass('is-shown'); + } + }); + }); + }; +}); diff --git a/cms/static/js/factories/settings.js b/cms/static/js/factories/settings.js new file mode 100644 index 0000000000..09ec56c0e8 --- /dev/null +++ b/cms/static/js/factories/settings.js @@ -0,0 +1,29 @@ +define([ + 'jquery', 'js/models/settings/course_details', 'js/views/settings/main' +], function($, CourseDetailsModel, MainView) { + 'use strict'; + return function (detailsUrl) { + var model; + // highlighting labels when fields are focused in + $('form :input') + .focus(function() { + $('label[for="' + this.id + '"]').addClass('is-focused'); + }) + .blur(function() { + $('label').removeClass('is-focused'); + }); + + model = new CourseDetailsModel(); + model.urlRoot = detailsUrl; + model.fetch({ + success: function(model) { + var editor = new MainView({ + el: $('.settings-details'), + model: model + }); + editor.render(); + }, + reset: true + }); + }; +}); diff --git a/cms/static/js/factories/settings_advanced.js b/cms/static/js/factories/settings_advanced.js new file mode 100644 index 0000000000..b12c0cb147 --- /dev/null +++ b/cms/static/js/factories/settings_advanced.js @@ -0,0 +1,44 @@ +define([ + 'jquery', 'gettext', 'js/models/settings/advanced', 'js/views/settings/advanced' +], function($, gettext, AdvancedSettingsModel, AdvancedSettingsView) { + 'use strict'; + return function (advancedDict, advancedSettingsUrl) { + var advancedModel, editor; + + $('form :input') + .focus(function() { + $('label[for="' + this.id + '"]').addClass('is-focused'); + }) + .blur(function() { + $('label').removeClass('is-focused'); + }); + + // proactively populate advanced b/c it has the filtered list and doesn't really follow the model pattern + advancedModel = new AdvancedSettingsModel(advancedDict, {parse: true}); + advancedModel.url = advancedSettingsUrl; + + editor = new AdvancedSettingsView({ + el: $('.settings-advanced'), + model: advancedModel + }); + editor.render(); + + $('#deprecated-settings').click(function() { + var wrapperDeprecatedSetting = $('.wrapper-deprecated-setting'), + deprecatedSettingsLabel = $('.deprecated-settings-label'); + + if ($(this).is(':checked')) { + wrapperDeprecatedSetting.addClass('is-set'); + deprecatedSettingsLabel.text(gettext('Hide Deprecated Settings')); + editor.render_deprecated = true; + } + else { + wrapperDeprecatedSetting.removeClass('is-set'); + deprecatedSettingsLabel.text(gettext('Show Deprecated Settings')); + editor.render_deprecated = false; + } + + editor.render(); + }); + }; +}); diff --git a/cms/static/js/factories/settings_graders.js b/cms/static/js/factories/settings_graders.js new file mode 100644 index 0000000000..4ed4e02df9 --- /dev/null +++ b/cms/static/js/factories/settings_graders.js @@ -0,0 +1,24 @@ +define([ + 'jquery', 'js/views/settings/grading', 'js/models/settings/course_grading_policy' +], function($, GradingView, CourseGradingPolicyModel) { + 'use strict'; + return function (courseDetails, gradingUrl) { + var model, editor; + + $('form :input') + .focus(function() { + $('label[for="' + this.id + '"]').addClass('is-focused'); + }) + .blur(function() { + $('label').removeClass('is-focused'); + }); + + model = new CourseGradingPolicyModel(courseDetails,{parse:true}); + model.urlRoot = gradingUrl; + editor = new GradingView({ + el: $('.settings-grading'), + model : model + }); + editor.render(); + }; +}); diff --git a/cms/static/js/factories/textbooks.js b/cms/static/js/factories/textbooks.js new file mode 100644 index 0000000000..fc0c301e1d --- /dev/null +++ b/cms/static/js/factories/textbooks.js @@ -0,0 +1,20 @@ +define([ + 'gettext', 'js/models/section', 'js/collections/textbook', 'js/views/list_textbooks' +], function(gettext, Section, TextbookCollection, ListTextbooksView) { + 'use strict'; + return function (textbooksJson) { + var textbooks = new TextbookCollection(textbooksJson, {parse: true}), + tbView = new ListTextbooksView({collection: textbooks}); + + $('.content-primary').append(tbView.render().el); + $('.nav-actions .new-button').click(function(event) { + tbView.addOne(event); + }); + $(window).on('beforeunload', function() { + var dirty = textbooks.find(function(textbook) { return textbook.isDirty(); }); + if(dirty) { + return gettext('You have unsaved changes. Do you really want to leave this page?'); + } + }); + }; +}); diff --git a/cms/static/js_test.yml b/cms/static/js_test.yml index 339d050015..02c1979e93 100644 --- a/cms/static/js_test.yml +++ b/cms/static/js_test.yml @@ -68,6 +68,7 @@ src_paths: - coffee/src - js - js/common_helpers + - js/factories # Paths to spec (test) JavaScript files spec_paths: diff --git a/cms/static/require-config.js b/cms/static/require-config.js new file mode 100644 index 0000000000..bbfd8a5908 --- /dev/null +++ b/cms/static/require-config.js @@ -0,0 +1,266 @@ +require.config({ + // NOTE: baseUrl has been previously set in cms/static/templates/base.html + waitSeconds: 60, + paths: { + "domReady": "js/vendor/domReady", + "gettext": "/i18n", + "mustache": "js/vendor/mustache", + "codemirror": "js/vendor/codemirror-compressed", + "codemirror/stex": "js/vendor/CodeMirror/stex", + "jquery": "js/vendor/jquery.min", + "jquery.ui": "js/vendor/jquery-ui.min", + "jquery.form": "js/vendor/jquery.form", + "jquery.markitup": "js/vendor/markitup/jquery.markitup", + "jquery.leanModal": "js/vendor/jquery.leanModal.min", + "jquery.ajaxQueue": "js/vendor/jquery.ajaxQueue", + "jquery.smoothScroll": "js/vendor/jquery.smooth-scroll.min", + "jquery.timepicker": "js/vendor/timepicker/jquery.timepicker", + "jquery.cookie": "js/vendor/jquery.cookie", + "jquery.qtip": "js/vendor/jquery.qtip.min", + "jquery.scrollTo": "js/vendor/jquery.scrollTo-1.4.2-min", + "jquery.flot": "js/vendor/flot/jquery.flot.min", + "jquery.fileupload": "js/vendor/jQuery-File-Upload/js/jquery.fileupload", + "jquery.iframe-transport": "js/vendor/jQuery-File-Upload/js/jquery.iframe-transport", + "jquery.inputnumber": "js/vendor/html5-input-polyfills/number-polyfill", + "jquery.immediateDescendents": "coffee/src/jquery.immediateDescendents", + "datepair": "js/vendor/timepicker/datepair", + "date": "js/vendor/date", + "underscore": "js/vendor/underscore-min", + "underscore.string": "js/vendor/underscore.string.min", + "backbone": "js/vendor/backbone-min", + "backbone.associations": "js/vendor/backbone-associations-min", + "backbone.paginator": "js/vendor/backbone.paginator.min", + "tinymce": "js/vendor/tinymce/js/tinymce/tinymce.full.min", + "jquery.tinymce": "js/vendor/tinymce/js/tinymce/jquery.tinymce.min", + "xmodule": "/xmodule/xmodule", + "xblock": "coffee/src/xblock", + "utility": "js/src/utility", + "accessibility": "js/src/accessibility_tools", + "draggabilly": "js/vendor/draggabilly.pkgd", + "URI": "js/vendor/URI.min", + "ieshim": "js/src/ie_shim", + "tooltip_manager": "js/src/tooltip_manager", + + // Files needed for Annotations feature + "annotator": "js/vendor/ova/annotator-full", + "annotator-harvardx": "js/vendor/ova/annotator-full-firebase-auth", + "video.dev": "js/vendor/ova/video.dev", + "vjs.youtube": 'js/vendor/ova/vjs.youtube', + "rangeslider": 'js/vendor/ova/rangeslider', + "share-annotator": 'js/vendor/ova/share-annotator', + "richText-annotator": 'js/vendor/ova/richText-annotator', + "reply-annotator": 'js/vendor/ova/reply-annotator', + "grouping-annotator": 'js/vendor/ova/grouping-annotator', + "tags-annotator": 'js/vendor/ova/tags-annotator', + "diacritic-annotator": 'js/vendor/ova/diacritic-annotator', + "flagging-annotator": 'js/vendor/ova/flagging-annotator', + "jquery-Watch": 'js/vendor/ova/jquery-Watch', + "openseadragon": 'js/vendor/ova/openseadragon', + "osda": 'js/vendor/ova/OpenSeaDragonAnnotation', + "ova": 'js/vendor/ova/ova', + "catch": 'js/vendor/ova/catch/js/catch', + "handlebars": 'js/vendor/ova/catch/js/handlebars-1.1.2', + // end of Annotation tool files + + // externally hosted files + "tender": [ + "//edxedge.tenderapp.com/tender_widget", + // if tender fails to load, fallback on a local file + // so that require doesn't fall over + "js/src/tender_fallback" + ], + "mathjax": "//edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full&delayStartupUntil=configured", + "youtube": [ + // youtube URL does not end in ".js". We add "?noext" to the path so + // that require.js adds the ".js" to the query component of the URL, + // and leaves the path component intact. + "//www.youtube.com/player_api?noext", + // if youtube fails to load, fallback on a local file + // so that require doesn't fall over + "js/src/youtube_fallback" + ] + }, + shim: { + "gettext": { + exports: "gettext" + }, + "date": { + exports: "Date" + }, + "jquery.ui": { + deps: ["jquery"], + exports: "jQuery.ui" + }, + "jquery.form": { + deps: ["jquery"], + exports: "jQuery.fn.ajaxForm" + }, + "jquery.markitup": { + deps: ["jquery"], + exports: "jQuery.fn.markitup" + }, + "jquery.leanmodal": { + deps: ["jquery"], + exports: "jQuery.fn.leanModal" + }, + "jquery.ajaxQueue": { + deps: ["jquery"], + exports: "jQuery.fn.ajaxQueue" + }, + "jquery.smoothScroll": { + deps: ["jquery"], + exports: "jQuery.fn.smoothScroll" + }, + "jquery.cookie": { + deps: ["jquery"], + exports: "jQuery.fn.cookie" + }, + "jquery.qtip": { + deps: ["jquery"], + exports: "jQuery.fn.qtip" + }, + "jquery.scrollTo": { + deps: ["jquery"], + exports: "jQuery.fn.scrollTo", + }, + "jquery.flot": { + deps: ["jquery"], + exports: "jQuery.fn.plot" + }, + "jquery.fileupload": { + deps: ["jquery.iframe-transport"], + exports: "jQuery.fn.fileupload" + }, + "jquery.inputnumber": { + deps: ["jquery"], + exports: "jQuery.fn.inputNumber" + }, + "jquery.tinymce": { + deps: ["jquery", "tinymce"], + exports: "jQuery.fn.tinymce" + }, + "datepair": { + deps: ["jquery.ui", "jquery.timepicker"] + }, + "underscore": { + exports: "_" + }, + "backbone": { + deps: ["underscore", "jquery"], + exports: "Backbone" + }, + "backbone.associations": { + deps: ["backbone"], + exports: "Backbone.Associations" + }, + "backbone.paginator": { + deps: ["backbone"], + exports: "Backbone.Paginator" + }, + "tender": { + exports: 'Tender' + }, + "youtube": { + exports: "YT" + }, + "codemirror": { + exports: "CodeMirror" + }, + "codemirror/stex": { + deps: ["codemirror"] + }, + "tinymce": { + exports: "tinymce" + }, + "mathjax": { + exports: "MathJax", + init: function() { + MathJax.Hub.Config({ + tex2jax: { + inlineMath: [ + ["\\(","\\)"], + ['[mathjaxinline]','[/mathjaxinline]'] + ], + displayMath: [ + ["\\[","\\]"], + ['[mathjax]','[/mathjax]'] + ] + } + }); + MathJax.Hub.Configured(); + } + }, + "URI": { + exports: "URI" + }, + "tooltip_manager": { + deps: ["jquery", "underscore"] + }, + "jquery.immediateDescendents": { + deps: ["jquery"] + }, + "xblock/core": { + exports: "XBlock", + deps: ["jquery", "jquery.immediateDescendents"] + }, + "xblock/runtime.v1": { + exports: "XBlock", + deps: ["xblock/core"] + }, + + "coffee/src/main": { + deps: ["coffee/src/ajax_prefix"] + }, + "coffee/src/logger": { + exports: "Logger", + deps: ["coffee/src/ajax_prefix"] + }, + + // the following are all needed for annotation tools + "video.dev": { + exports:"videojs" + }, + "vjs.youtube": { + deps: ["video.dev"] + }, + "rangeslider": { + deps: ["video.dev"] + }, + "annotator": { + exports: "Annotator" + }, + "annotator-harvardx":{ + deps: ["annotator"] + }, + "share-annotator": { + deps: ["annotator"] + }, + "richText-annotator": { + deps: ["annotator", "tinymce"] + }, + "reply-annotator": { + deps: ["annotator"] + }, + "tags-annotator": { + deps: ["annotator"] + }, + "diacritic-annotator": { + deps: ["annotator"] + }, + "flagging-annotator": { + deps: ["annotator"] + }, + "grouping-annotator": { + deps: ["annotator"] + }, + "ova":{ + exports: "ova", + deps: ["annotator", "annotator-harvardx", "video.dev", "vjs.youtube", "rangeslider", "share-annotator", "richText-annotator", "reply-annotator", "tags-annotator", "flagging-annotator", "grouping-annotator", "diacritic-annotator", "jquery-Watch", "catch", "handlebars", "URI"] + }, + "osda":{ + exports: "osda", + deps: ["annotator", "annotator-harvardx", "video.dev", "vjs.youtube", "rangeslider", "share-annotator", "richText-annotator", "reply-annotator", "tags-annotator", "flagging-annotator", "grouping-annotator", "diacritic-annotator", "openseadragon", "jquery-Watch", "catch", "handlebars", "URI"] + }, + // end of annotation tool files + } +}); diff --git a/cms/static/sass/elements/_header.scss b/cms/static/sass/elements/_header.scss index 3bfafc7d28..afc4eabaaa 100644 --- a/cms/static/sass/elements/_header.scss +++ b/cms/static/sass/elements/_header.scss @@ -201,7 +201,7 @@ > .label { display: inline-block; - max-width: 85%; + max-width: 84%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index ab36424574..7e886b7241 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -17,18 +17,10 @@ % endfor %block> -<%block name="jsextra"> - +<%block name="requirejs"> + require(["js/factories/asset_index"], function (AssetIndexFactory) { + AssetIndexFactory("${asset_callback_url}"); + }); %block> <%block name="content"> @@ -72,9 +64,9 @@${_("Use the {em_start}Embed URL{em_end} value to link to the file or image from a component, a course update, or a course handout.").format(em_start='', em_end="")}
- +${_("Use the {em_start}External URL{em_end} value to reference the file or image only from outside of your course.").format(em_start='', em_end="")}
${_("Click in the Embed URL or External URL column to select the value, then copy it.")}