diff --git a/.coveragerc b/.coveragerc
index cb75a6a94e..de7da357c1 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -26,6 +26,8 @@ omit =
openedx/core/djangoapps/*/migrations/*
openedx/core/djangoapps/debug/*
+concurrency=multiprocessing
+
[report]
ignore_errors = True
diff --git a/.jshintignore b/.jshintignore
index 26e4c15e1e..0065f2da20 100644
--- a/.jshintignore
+++ b/.jshintignore
@@ -1,4 +1,6 @@
**/vendor
+cms/static/cms/js/build.js
+cms/static/cms/js/spec/main.js
cms/static/js/i18n/**/*.js
lms/static/js/i18n/**/*.js
lms/static/lms/js/build.js
diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature
deleted file mode 100644
index cd34107f39..0000000000
--- a/cms/djangoapps/contentstore/features/component.feature
+++ /dev/null
@@ -1,61 +0,0 @@
-@shard_1
-Feature: CMS.Component Adding
- As a course author, I want to be able to add a wide variety of components
-
- Scenario: I can add HTML components
- Given I am in Studio editing a new unit
- When I add this type of HTML component:
- | Component |
- | Text |
- | Announcement |
- | Zooming Image Tool |
- | Raw HTML |
- Then I see HTML components in this order:
- | Component |
- | Text |
- | Announcement |
- | Zooming Image Tool |
- | Raw HTML |
-
- Scenario: I can add Latex HTML components
- Given I am in Studio editing a new unit
- Given I have enabled latex compiler
- When I add this type of HTML component:
- | Component |
- | E-text Written in LaTeX |
- Then I see HTML components in this order:
- | Component |
- | E-text Written in LaTeX |
-
- Scenario: I can add Common Problem components
- Given I am in Studio editing a new unit
- When I add this type of Problem component:
- | Component |
- | Blank Common Problem |
- | Checkboxes |
- | Dropdown |
- | Multiple Choice |
- | Numerical Input |
- | Text Input |
- Then I see Problem components in this order:
- | Component |
- | Blank Common Problem |
- | Checkboxes |
- | Dropdown |
- | Multiple Choice |
- | Numerical Input |
- | Text Input |
-
-# Disabled 1/21/14 due to flakiness seen in master
-# Scenario: I can add Advanced Latex Problem components
-# Given I am in Studio editing a new unit
-# Given I have enabled latex compiler
-# When I add a "" "Advanced Problem" component
-# Then I see a "" Problem component
-# # Flush out the database before the next example executes
-# And I reset the database
-
-# Examples:
-# | Component |
-# | Problem Written in LaTeX |
-# | Problem with Adaptive Hint in Latex |
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index 2bb272bcfe..0f87ced3f1 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -1000,7 +1000,7 @@ class MiscCourseTests(ContentStoreTestCase):
3) computing thumbnail location of asset
4) deleting the asset from the course
"""
- asset_key = self.course.id.make_asset_key('asset', 'sample_static.txt')
+ asset_key = self.course.id.make_asset_key('asset', 'sample_static.html')
content = StaticContent(
asset_key, "Fake asset", "application/text", "test",
)
@@ -1072,7 +1072,7 @@ class MiscCourseTests(ContentStoreTestCase):
draft content is also deleted
"""
# add an asset
- asset_key = self.course.id.make_asset_key('asset', 'sample_static.txt')
+ asset_key = self.course.id.make_asset_key('asset', 'sample_static.html')
content = StaticContent(
asset_key, "Fake asset", "application/text", "test",
)
diff --git a/cms/djangoapps/contentstore/tests/test_orphan.py b/cms/djangoapps/contentstore/tests/test_orphan.py
index 519276cbc2..5d089bdd8f 100644
--- a/cms/djangoapps/contentstore/tests/test_orphan.py
+++ b/cms/djangoapps/contentstore/tests/test_orphan.py
@@ -101,7 +101,7 @@ class TestOrphan(TestOrphanBase):
@ddt.data(
(ModuleStoreEnum.Type.split, 9, 6),
- (ModuleStoreEnum.Type.mongo, 30, 13),
+ (ModuleStoreEnum.Type.mongo, 34, 13),
)
@ddt.unpack
def test_delete_orphans(self, default_store, max_mongo_calls, min_mongo_calls):
diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py
index 2026295c9e..af5e7b8e52 100644
--- a/cms/djangoapps/contentstore/tests/utils.py
+++ b/cms/djangoapps/contentstore/tests/utils.py
@@ -121,7 +121,7 @@ class CourseTestCase(ProceduralCourseTestMixin, ModuleStoreTestCase):
SEQUENTIAL = 'vertical_sequential'
DRAFT_HTML = 'draft_html'
DRAFT_VIDEO = 'draft_video'
- LOCKED_ASSET_KEY = AssetLocation.from_deprecated_string('/c4x/edX/toy/asset/sample_static.txt')
+ LOCKED_ASSET_KEY = AssetLocation.from_deprecated_string('/c4x/edX/toy/asset/sample_static.html')
def import_and_populate_course(self):
"""
diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py
index 78ce5b4050..797b76be59 100644
--- a/cms/djangoapps/contentstore/views/tests/test_assets.py
+++ b/cms/djangoapps/contentstore/views/tests/test_assets.py
@@ -126,7 +126,7 @@ class BasicAssetsTestCase(AssetsTestCase):
)
course = module_store.get_course(course_id)
- filename = 'sample_static.txt'
+ filename = 'sample_static.html'
html_src_attribute = '"/static/{}"'.format(filename)
asset_url = replace_static_urls(html_src_attribute, course_id=course.id)
url = asset_url.replace('"', '')
@@ -379,7 +379,7 @@ class LockAssetTestCase(AssetsTestCase):
"""
def verify_asset_locked_state(locked):
""" Helper method to verify lock state in the contentstore """
- asset_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt')
+ asset_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.html')
content = contentstore().find(asset_location)
self.assertEqual(content.locked, locked)
@@ -387,14 +387,14 @@ class LockAssetTestCase(AssetsTestCase):
""" Helper method for posting asset update. """
content_type = 'application/txt'
upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
- asset_location = course.id.make_asset_key('asset', 'sample_static.txt')
+ asset_location = course.id.make_asset_key('asset', 'sample_static.html')
url = reverse_course_url('assets_handler', course.id, kwargs={'asset_key_string': unicode(asset_location)})
resp = self.client.post(
url,
# pylint: disable=protected-access
json.dumps(assets._get_asset_json(
- "sample_static.txt", content_type, upload_date, asset_location, None, lock)),
+ "sample_static.html", content_type, upload_date, asset_location, None, lock)),
"application/json"
)
diff --git a/cms/djangoapps/contentstore/views/videos.py b/cms/djangoapps/contentstore/views/videos.py
index 0c047f8ba1..ec34567f9f 100644
--- a/cms/djangoapps/contentstore/views/videos.py
+++ b/cms/djangoapps/contentstore/views/videos.py
@@ -44,6 +44,9 @@ class StatusDisplayStrings(object):
_COMPLETE = ugettext_noop("Ready")
# Translators: This is the status for a video that the servers have failed to process
_FAILED = ugettext_noop("Failed")
+ # Translators: This is the status for a video which has failed
+ # due to being flagged as a duplicate by an external or internal CMS
+ _DUPLICATE = ugettext_noop("Failed Duplicate")
# Translators: This is the status for a video for which an invalid
# processing token was provided in the course settings
_INVALID_TOKEN = ugettext_noop("Invalid Token")
@@ -61,6 +64,7 @@ class StatusDisplayStrings(object):
"file_complete": _COMPLETE,
"file_corrupt": _FAILED,
"pipeline_error": _FAILED,
+ "duplicate": _DUPLICATE,
"invalid_token": _INVALID_TOKEN,
"imported": _IMPORTED,
}
@@ -311,9 +315,9 @@ def videos_post(course, request):
edx_video_id = unicode(uuid4())
key = storage_service_key(bucket, file_name=edx_video_id)
for metadata_name, value in [
- ("course_video_upload_token", course_video_upload_token),
- ("client_video_id", file_name),
- ("course_key", unicode(course.id)),
+ ("course_video_upload_token", course_video_upload_token),
+ ("client_video_id", file_name),
+ ("course_key", unicode(course.id)),
]:
key.set_metadata(metadata_name, value)
upload_url = key.generate_url(
diff --git a/cms/envs/common.py b/cms/envs/common.py
index a3204be72f..0d210366d3 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -620,7 +620,7 @@ PIPELINE_JS = {
'source_filenames': (
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js') +
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/modules/js/*.js') +
- rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/discussion/*.js')
+ rooted_glob(COMMON_ROOT / 'static/', 'common/js/discussion/*.js')
),
'output_filename': 'js/cms-modules.js',
'test_order': 1
diff --git a/cms/static/cms/js/build.js b/cms/static/cms/js/build.js
index 65e78c3e62..f6a3c1534c 100644
--- a/cms/static/cms/js/build.js
+++ b/cms/static/cms/js/build.js
@@ -1,8 +1,8 @@
-(function () {
+(function() {
'use strict';
var commonLibrariesPath = 'common/js/common_libraries';
- var getModule = function (moduleName, excludeCommonDeps) {
+ var getModule = function(moduleName, excludeCommonDeps) {
var module = {
name: moduleName
};
@@ -14,7 +14,7 @@
return module;
};
- var getModulesList = function (modules) {
+ var getModulesList = function(modules) {
var result = [getModule(commonLibrariesPath)];
return result.concat(modules.map(function (moduleName) {
return getModule(moduleName, true);
@@ -92,7 +92,7 @@
/**
* Stub out requireJS text in the optimized file, but leave available for non-optimized development use.
*/
- stubModules: ["text"],
+ stubModules: ['text'],
/**
* If shim config is used in the app during runtime, duplicate the config
@@ -170,4 +170,4 @@
*/
logLevel: 1
};
-} ())
+}())
diff --git a/cms/static/cms/js/require-config.js b/cms/static/cms/js/require-config.js
index 70e8d61e0b..7cd1f1caf1 100644
--- a/cms/static/cms/js/require-config.js
+++ b/cms/static/cms/js/require-config.js
@@ -1,5 +1,6 @@
-;(function (require, define) {
+;(function(require, define) {
'use strict';
+
if (window) {
// MathJax Fast Preview was introduced in 2.5. However, it
// causes undesirable flashing/font size changes when
@@ -16,300 +17,300 @@
// needs to be served. To handle this, we load the correct file in the
// rendered template and then use this to ensure that RequireJS knows
// how to find it.
- define("gettext", function () { return window.gettext; });
+ define('gettext', function() { return window.gettext; });
}
require.config({
// NOTE: baseUrl has been previously set in cms/static/templates/base.html
waitSeconds: 60,
paths: {
- "domReady": "js/vendor/domReady",
- "mustache": "js/vendor/mustache",
- "codemirror": "js/vendor/codemirror-compressed",
- "codemirror/stex": "js/vendor/CodeMirror/stex",
- "jquery": "common/js/vendor/jquery",
- "jquery-migrate": "common/js/vendor/jquery-migrate",
- "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",
- "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": "common/js/vendor/jquery.scrollTo",
- "jquery.flot": "js/vendor/flot/jquery.flot.min",
- "jquery.fileupload": "js/vendor/jQuery-File-Upload/js/jquery.fileupload",
- "jquery.fileupload-process": "js/vendor/jQuery-File-Upload/js/jquery.fileupload-process",
- "jquery.fileupload-validate": "js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate",
- "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",
- "moment": "js/vendor/moment.min",
- "moment-with-locales": "js/vendor/moment-with-locales.min",
- "text": 'js/vendor/requirejs/text',
- "underscore": "common/js/vendor/underscore",
- "underscore.string": "common/js/vendor/underscore.string",
- "backbone": "common/js/vendor/backbone",
- "backbone-relational" : "js/vendor/backbone-relational.min",
- "backbone.associations": "js/vendor/backbone-associations-min",
- "backbone.paginator": "common/js/vendor/backbone.paginator",
- "tinymce": "js/vendor/tinymce/js/tinymce/tinymce.full.min",
- "jquery.tinymce": "js/vendor/tinymce/js/tinymce/jquery.tinymce.min",
- "xmodule": "/xmodule/xmodule",
- "xblock/core": "js/xblock/core",
- "xblock": "coffee/src/xblock",
- "utility": "js/src/utility",
- "accessibility": "js/src/accessibility_tools",
- "URI": "js/vendor/URI.min",
- "ieshim": "js/src/ie_shim",
- "tooltip_manager": "js/src/tooltip_manager",
- "modernizr": "edx-pattern-library/js/modernizr-custom",
- "afontgarde": "edx-pattern-library/js/afontgarde",
- "edxicons": "edx-pattern-library/js/edx-icons",
- "draggabilly": "js/vendor/draggabilly",
+ 'domReady': 'js/vendor/domReady',
+ 'mustache': 'js/vendor/mustache',
+ 'codemirror': 'js/vendor/codemirror-compressed',
+ 'codemirror/stex': 'js/vendor/CodeMirror/stex',
+ 'jquery': 'common/js/vendor/jquery',
+ 'jquery-migrate': 'common/js/vendor/jquery-migrate',
+ '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',
+ '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': 'common/js/vendor/jquery.scrollTo',
+ 'jquery.flot': 'js/vendor/flot/jquery.flot.min',
+ 'jquery.fileupload': 'js/vendor/jQuery-File-Upload/js/jquery.fileupload',
+ 'jquery.fileupload-process': 'js/vendor/jQuery-File-Upload/js/jquery.fileupload-process',
+ 'jquery.fileupload-validate': 'js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate',
+ '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',
+ 'moment': 'js/vendor/moment.min',
+ 'moment-with-locales': 'js/vendor/moment-with-locales.min',
+ 'text': 'js/vendor/requirejs/text',
+ 'underscore': 'common/js/vendor/underscore',
+ 'underscore.string': 'common/js/vendor/underscore.string',
+ 'backbone': 'common/js/vendor/backbone',
+ 'backbone-relational': 'js/vendor/backbone-relational.min',
+ 'backbone.associations': 'js/vendor/backbone-associations-min',
+ 'backbone.paginator': 'common/js/vendor/backbone.paginator',
+ 'tinymce': 'js/vendor/tinymce/js/tinymce/tinymce.full.min',
+ 'jquery.tinymce': 'js/vendor/tinymce/js/tinymce/jquery.tinymce.min',
+ 'xmodule': '/xmodule/xmodule',
+ 'xblock/cms.runtime.v1': 'cms/js/xblock/cms.runtime.v1',
+ 'xblock': 'common/js/xblock',
+ 'utility': 'js/src/utility',
+ 'accessibility': 'js/src/accessibility_tools',
+ 'URI': 'js/vendor/URI.min',
+ 'ieshim': 'js/src/ie_shim',
+ 'tooltip_manager': 'js/src/tooltip_manager',
+ 'modernizr': 'edx-pattern-library/js/modernizr-custom',
+ 'afontgarde': 'edx-pattern-library/js/afontgarde',
+ 'edxicons': 'edx-pattern-library/js/edx-icons',
+ 'draggabilly': 'js/vendor/draggabilly',
// 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',
- "lang_edx": "js/src/lang_edx",
+ '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',
+ 'lang_edx': 'js/src/lang_edx',
// end of Annotation tool files
// externally hosted files
- "mathjax": "//cdn.mathjax.org/mathjax/2.6-latest/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured", // jshint ignore:line
- "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,
+ 'mathjax': '//cdn.mathjax.org/mathjax/2.6-latest/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // jshint ignore:line
+ '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",
+ '//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"
+ 'js/src/youtube_fallback'
]
},
shim: {
- "gettext": {
- exports: "gettext"
+ 'gettext': {
+ exports: 'gettext'
},
- "date": {
- exports: "Date"
+ 'date': {
+ exports: 'Date'
},
- "jquery-migrate": ['jquery'],
- "jquery.ui": {
- deps: ["jquery"],
- exports: "jQuery.ui"
+ 'jquery-migrate': ['jquery'],
+ 'jquery.ui': {
+ deps: ['jquery'],
+ exports: 'jQuery.ui'
},
- "jquery.form": {
- deps: ["jquery"],
- exports: "jQuery.fn.ajaxForm"
+ 'jquery.form': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.ajaxForm'
},
- "jquery.markitup": {
- deps: ["jquery"],
- exports: "jQuery.fn.markitup"
+ 'jquery.markitup': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.markitup'
},
- "jquery.leanmodal": {
- deps: ["jquery"],
- exports: "jQuery.fn.leanModal"
+ 'jquery.leanmodal': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.leanModal'
},
- "jquery.ajaxQueue": {
- deps: ["jquery"],
- exports: "jQuery.fn.ajaxQueue"
+ 'jquery.ajaxQueue': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.ajaxQueue'
},
- "jquery.smoothScroll": {
- deps: ["jquery"],
- exports: "jQuery.fn.smoothScroll"
+ 'jquery.smoothScroll': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.smoothScroll'
},
- "jquery.cookie": {
- deps: ["jquery"],
- exports: "jQuery.fn.cookie"
+ 'jquery.cookie': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.cookie'
},
- "jquery.qtip": {
- deps: ["jquery"],
- exports: "jQuery.fn.qtip"
+ 'jquery.qtip': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.qtip'
},
- "jquery.scrollTo": {
- deps: ["jquery"],
- exports: "jQuery.fn.scrollTo"
+ 'jquery.scrollTo': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.scrollTo'
},
- "jquery.flot": {
- deps: ["jquery"],
- exports: "jQuery.fn.plot"
+ 'jquery.flot': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.plot'
},
- "jquery.fileupload": {
- deps: ["jquery.ui", "jquery.iframe-transport"],
- exports: "jQuery.fn.fileupload"
+ 'jquery.fileupload': {
+ deps: ['jquery.ui', 'jquery.iframe-transport'],
+ exports: 'jQuery.fn.fileupload'
},
- "jquery.fileupload-process": {
- deps: ["jquery.fileupload"]
+ 'jquery.fileupload-process': {
+ deps: ['jquery.fileupload']
},
- "jquery.fileupload-validate": {
- deps: ["jquery.fileupload"]
+ 'jquery.fileupload-validate': {
+ deps: ['jquery.fileupload']
},
- "jquery.inputnumber": {
- deps: ["jquery"],
- exports: "jQuery.fn.inputNumber"
+ 'jquery.inputnumber': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.inputNumber'
},
- "jquery.tinymce": {
- deps: ["jquery", "tinymce"],
- exports: "jQuery.fn.tinymce"
+ 'jquery.tinymce': {
+ deps: ['jquery', 'tinymce'],
+ exports: 'jQuery.fn.tinymce'
},
- "datepair": {
- deps: ["jquery.ui", "jquery.timepicker"]
+ 'datepair': {
+ deps: ['jquery.ui', 'jquery.timepicker']
},
- "underscore": {
- exports: "_"
+ 'underscore': {
+ exports: '_'
},
- "backbone": {
- deps: ["underscore", "jquery"],
- exports: "Backbone"
+ 'backbone': {
+ deps: ['underscore', 'jquery'],
+ exports: 'Backbone'
},
- "backbone.associations": {
- deps: ["backbone"],
- exports: "Backbone.Associations"
+ 'backbone.associations': {
+ deps: ['backbone'],
+ exports: 'Backbone.Associations'
},
- "backbone.paginator": {
- deps: ["backbone"],
- exports: "Backbone.PageableCollection"
+ 'backbone.paginator': {
+ deps: ['backbone'],
+ exports: 'Backbone.PageableCollection'
},
- "youtube": {
- exports: "YT"
+ 'youtube': {
+ exports: 'YT'
},
- "codemirror": {
- exports: "CodeMirror"
+ 'codemirror': {
+ exports: 'CodeMirror'
},
- "codemirror/stex": {
- deps: ["codemirror"]
+ 'codemirror/stex': {
+ deps: ['codemirror']
},
- "tinymce": {
- exports: "tinymce"
+ 'tinymce': {
+ exports: 'tinymce'
},
- "lang_edx": {
- deps: ["jquery"]
+ 'lang_edx': {
+ deps: ['jquery']
},
- "mathjax": {
- exports: "MathJax",
+ 'mathjax': {
+ exports: 'MathJax',
init: function() {
window.MathJax.Hub.Config({
tex2jax: {
inlineMath: [
- ["\\(","\\)"],
+ ['\\(','\\)'],
['[mathjaxinline]','[/mathjaxinline]']
],
displayMath: [
- ["\\[","\\]"],
+ ['\\[','\\]'],
['[mathjax]','[/mathjax]']
]
}
- });
- // In order to eliminate all flashing during interactive
- // preview, it is necessary to set processSectionDelay to 0
- // (remove delay between input and output phases). This
- // effectively disables fast preview, regardless of
- // the fast preview setting as shown in the context menu.
- window.MathJax.Hub.processSectionDelay = 0;
- window.MathJax.Hub.Configured();
+ });
+ // In order to eliminate all flashing during interactive
+ // preview, it is necessary to set processSectionDelay to 0
+ // (remove delay between input and output phases). This
+ // effectively disables fast preview, regardless of
+ // the fast preview setting as shown in the context menu.
+ window.MathJax.Hub.processSectionDelay = 0;
+ window.MathJax.Hub.Configured();
}
},
- "URI": {
- exports: "URI"
+ 'URI': {
+ exports: 'URI'
},
- "tooltip_manager": {
- deps: ["jquery", "underscore"]
+ 'tooltip_manager': {
+ deps: ['jquery', 'underscore']
},
- "jquery.immediateDescendents": {
- deps: ["jquery"]
+ 'jquery.immediateDescendents': {
+ deps: ['jquery']
},
- "xblock/core": {
- exports: "XBlock",
- deps: ["jquery", "jquery.immediateDescendents"]
+ 'xblock/core': {
+ exports: 'XBlock',
+ deps: ['jquery', 'jquery.immediateDescendents']
},
- "xblock/runtime.v1": {
- exports: "XBlock",
- deps: ["xblock/core"]
+ 'xblock/runtime.v1': {
+ exports: 'XBlock',
+ deps: ['xblock/core']
},
- "coffee/src/main": {
- deps: ["coffee/src/ajax_prefix"]
+ 'coffee/src/main': {
+ deps: ['coffee/src/ajax_prefix']
},
- "js/src/logger": {
- exports: "Logger",
- deps: ["coffee/src/ajax_prefix"]
+ 'js/src/logger': {
+ exports: 'Logger',
+ deps: ['coffee/src/ajax_prefix']
},
- "modernizr": {
- exports: "Modernizr"
+ 'modernizr': {
+ exports: 'Modernizr'
},
- "afontgarde": {
- exports: "AFontGarde"
+ 'afontgarde': {
+ exports: 'AFontGarde'
},
// the following are all needed for annotation tools
- "video.dev": {
- exports:"videojs"
+ 'video.dev': {
+ exports: 'videojs'
},
- "vjs.youtube": {
- deps: ["video.dev"]
+ 'vjs.youtube': {
+ deps: ['video.dev']
},
- "rangeslider": {
- deps: ["video.dev"]
+ 'rangeslider': {
+ deps: ['video.dev']
},
- "annotator": {
- exports: "Annotator"
+ 'annotator': {
+ exports: 'Annotator'
},
- "annotator-harvardx":{
- deps: ["annotator"]
+ 'annotator-harvardx': {
+ deps: ['annotator']
},
- "share-annotator": {
- deps: ["annotator"]
+ 'share-annotator': {
+ deps: ['annotator']
},
- "richText-annotator": {
- deps: ["annotator", "tinymce"]
+ 'richText-annotator': {
+ deps: ['annotator', 'tinymce']
},
- "reply-annotator": {
- deps: ["annotator"]
+ 'reply-annotator': {
+ deps: ['annotator']
},
- "tags-annotator": {
- deps: ["annotator"]
+ 'tags-annotator': {
+ deps: ['annotator']
},
- "diacritic-annotator": {
- deps: ["annotator"]
+ 'diacritic-annotator': {
+ deps: ['annotator']
},
- "flagging-annotator": {
- deps: ["annotator"]
+ 'flagging-annotator': {
+ deps: ['annotator']
},
- "grouping-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"]
+ '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"]
+ '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/cms/js/spec/main.js b/cms/static/cms/js/spec/main.js
new file mode 100644
index 0000000000..db82630e76
--- /dev/null
+++ b/cms/static/cms/js/spec/main.js
@@ -0,0 +1,299 @@
+(function(requirejs, requireSerial) {
+ 'use strict';
+
+ var i, specHelpers, testFiles;
+
+ requirejs.config({
+ baseUrl: '/base/',
+ paths: {
+ 'gettext': 'xmodule_js/common_static/js/test/i18n',
+ 'mustache': 'xmodule_js/common_static/js/vendor/mustache',
+ 'codemirror': 'xmodule_js/common_static/js/vendor/CodeMirror/codemirror',
+ 'jquery': 'xmodule_js/common_static/common/js/vendor/jquery',
+ 'jquery-migrate': 'xmodule_js/common_static/common/js/vendor/jquery-migrate',
+ 'jquery.ui': 'xmodule_js/common_static/js/vendor/jquery-ui.min',
+ 'jquery.form': 'xmodule_js/common_static/js/vendor/jquery.form',
+ 'jquery.markitup': 'xmodule_js/common_static/js/vendor/markitup/jquery.markitup',
+ 'jquery.leanModal': 'xmodule_js/common_static/js/vendor/jquery.leanModal',
+ 'jquery.ajaxQueue': 'xmodule_js/common_static/js/vendor/jquery.ajaxQueue',
+ 'jquery.smoothScroll': 'xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min',
+ 'jquery.scrollTo': 'common/js/vendor/jquery.scrollTo',
+ 'jquery.timepicker': 'xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker',
+ 'jquery.cookie': 'xmodule_js/common_static/js/vendor/jquery.cookie',
+ 'jquery.qtip': 'xmodule_js/common_static/js/vendor/jquery.qtip.min',
+ 'jquery.fileupload': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload',
+ 'jquery.fileupload-process': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process', // jshint ignore:line
+ 'jquery.fileupload-validate': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate', // jshint ignore:line
+ 'jquery.iframe-transport': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport', // jshint ignore:line
+ 'jquery.inputnumber': 'xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill',
+ 'jquery.immediateDescendents': 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents',
+ 'jquery.simulate': 'xmodule_js/common_static/js/vendor/jquery.simulate',
+ 'datepair': 'xmodule_js/common_static/js/vendor/timepicker/datepair',
+ 'date': 'xmodule_js/common_static/js/vendor/date',
+ 'moment': 'xmodule_js/common_static/js/vendor/moment.min',
+ 'moment-with-locales': 'xmodule_js/common_static/js/vendor/moment-with-locales.min',
+ 'text': 'xmodule_js/common_static/js/vendor/requirejs/text',
+ 'underscore': 'common/js/vendor/underscore',
+ 'underscore.string': 'common/js/vendor/underscore.string',
+ 'backbone': 'common/js/vendor/backbone',
+ 'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
+ 'backbone.paginator': 'common/js/vendor/backbone.paginator',
+ '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',
+ 'xmodule': 'xmodule_js/src/xmodule',
+ 'xblock/cms.runtime.v1': 'cms/js/xblock/cms.runtime.v1',
+ 'xblock': 'common/js/xblock',
+ 'utility': 'xmodule_js/common_static/js/src/utility',
+ 'accessibility': 'xmodule_js/common_static/js/src/accessibility_tools',
+ 'sinon': 'xmodule_js/common_static/js/vendor/sinon-1.17.0',
+ 'squire': 'xmodule_js/common_static/js/vendor/Squire',
+ 'jasmine-imagediff': 'xmodule_js/common_static/js/vendor/jasmine-imagediff',
+ 'draggabilly': 'xmodule_js/common_static/js/vendor/draggabilly',
+ 'domReady': 'xmodule_js/common_static/js/vendor/domReady',
+ 'URI': 'xmodule_js/common_static/js/vendor/URI.min',
+ 'mock-ajax': 'xmodule_js/common_static/js/vendor/mock-ajax',
+ 'modernizr': 'edx-pattern-library/js/modernizr-custom',
+ 'afontgarde': 'edx-pattern-library/js/afontgarde',
+ 'edxicons': 'edx-pattern-library/js/edx-icons',
+ 'mathjax': '//cdn.mathjax.org/mathjax/2.6-latest/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // jshint ignore:line
+ 'youtube': '//www.youtube.com/player_api?noext',
+ 'coffee/src/ajax_prefix': 'xmodule_js/common_static/coffee/src/ajax_prefix',
+ 'js/spec/test_utils': 'js/spec/test_utils'
+ },
+ shim: {
+ 'gettext': {
+ exports: 'gettext'
+ },
+ 'date': {
+ exports: 'Date'
+ },
+ 'jquery-migrate': ['jquery'],
+ '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.smoothScroll': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.smoothScroll'
+ },
+ 'jquery.ajaxQueue': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.ajaxQueue'
+ },
+ 'jquery.scrollTo': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.scrollTo'
+ },
+ 'jquery.cookie': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.cookie'
+ },
+ 'jquery.qtip': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.qtip'
+ },
+ 'jquery.fileupload': {
+ deps: ['jquery.ui', 'jquery.iframe-transport'],
+ exports: 'jQuery.fn.fileupload'
+ },
+ 'jquery.fileupload-process': {
+ deps: ['jquery.fileupload']
+ },
+ 'jquery.fileupload-validate': {
+ deps: ['jquery.fileupload']
+ },
+ 'jquery.inputnumber': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.inputNumber'
+ },
+ 'jquery.simulate': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.simulate'
+ },
+ '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.PageableCollection'
+ },
+ 'backbone-relational': {
+ deps: ['backbone']
+ },
+ 'youtube': {
+ exports: 'YT'
+ },
+ 'codemirror': {
+ exports: 'CodeMirror'
+ },
+ 'tinymce': {
+ exports: 'tinymce'
+ },
+ 'mathjax': {
+ exports: 'MathJax',
+ init: function() {
+ window.MathJax.Hub.Config({
+ tex2jax: {
+ inlineMath: [['\\(', '\\)'], ['[mathjaxinline]', '[/mathjaxinline]']],
+ displayMath: [['\\[', '\\]'], ['[mathjax]', '[/mathjax]']]
+ }
+ });
+ return window.MathJax.Hub.Configured();
+ }
+ },
+ 'URI': {
+ exports: 'URI'
+ },
+ 'xmodule': {
+ exports: 'XModule'
+ },
+ 'sinon': {
+ exports: 'sinon'
+ },
+ 'jasmine-imagediff': {},
+ 'common/js/spec_helpers/jasmine-extensions': {
+ deps: ['jquery']
+ },
+ 'common/js/spec_helpers/jasmine-stealth': {
+ deps: ['underscore', 'underscore.string']
+ },
+ 'common/js/spec_helpers/jasmine-waituntil': {
+ deps: ['jquery']
+ },
+ 'xblock/core': {
+ exports: 'XBlock',
+ deps: ['jquery', 'jquery.immediateDescendents']
+ },
+ 'xblock/runtime.v1': {
+ exports: 'XBlock',
+ deps: ['xblock/core']
+ },
+ 'mock-ajax': {
+ deps: ['jquery']
+ },
+ 'coffee/src/main': {
+ deps: ['coffee/src/ajax_prefix']
+ },
+ 'coffee/src/ajax_prefix': {
+ deps: ['jquery']
+ },
+ 'modernizr': {
+ exports: 'Modernizr'
+ },
+ 'afontgarde': {
+ exports: 'AFontGarde'
+ }
+ }
+ });
+
+ jasmine.getFixtures().fixturesPath += 'coffee/fixtures';
+
+ testFiles = [
+ 'cms/js/spec/xblock/cms.runtime.v1_spec',
+ 'coffee/spec/main_spec',
+ 'coffee/spec/models/course_spec',
+ 'coffee/spec/models/metadata_spec',
+ 'coffee/spec/models/section_spec',
+ 'coffee/spec/models/settings_course_grader_spec',
+ 'coffee/spec/models/settings_grading_spec',
+ 'coffee/spec/models/textbook_spec',
+ 'coffee/spec/models/upload_spec',
+ 'coffee/spec/views/course_info_spec',
+ 'coffee/spec/views/metadata_edit_spec',
+ 'coffee/spec/views/textbook_spec',
+ 'coffee/spec/views/upload_spec',
+ 'js/spec/video/transcripts/utils_spec',
+ 'js/spec/video/transcripts/editor_spec',
+ 'js/spec/video/transcripts/videolist_spec',
+ 'js/spec/video/transcripts/message_manager_spec',
+ 'js/spec/video/transcripts/file_uploader_spec',
+ 'js/spec/models/component_template_spec',
+ 'js/spec/models/explicit_url_spec',
+ 'js/spec/models/xblock_info_spec',
+ 'js/spec/models/xblock_validation_spec',
+ 'js/spec/models/license_spec',
+ 'js/spec/utils/drag_and_drop_spec',
+ 'js/spec/utils/handle_iframe_binding_spec',
+ 'js/spec/utils/module_spec',
+ 'js/spec/views/active_video_upload_list_spec',
+ 'js/spec/views/previous_video_upload_spec',
+ 'js/spec/views/previous_video_upload_list_spec',
+ 'js/spec/views/assets_spec',
+ 'js/spec/views/baseview_spec',
+ 'js/spec/views/container_spec',
+ 'js/spec/views/module_edit_spec',
+ 'js/spec/views/paged_container_spec',
+ 'js/spec/views/group_configuration_spec',
+ 'js/spec/views/unit_outline_spec',
+ 'js/spec/views/xblock_spec',
+ 'js/spec/views/xblock_editor_spec',
+ 'js/spec/views/xblock_string_field_editor_spec',
+ 'js/spec/views/xblock_validation_spec',
+ 'js/spec/views/license_spec',
+ 'js/spec/views/paging_spec',
+ 'js/spec/views/login_studio_spec',
+ 'js/spec/views/pages/container_spec',
+ 'js/spec/views/pages/container_subviews_spec',
+ 'js/spec/views/pages/group_configurations_spec',
+ 'js/spec/views/pages/course_outline_spec',
+ 'js/spec/views/pages/course_rerun_spec',
+ 'js/spec/views/pages/index_spec',
+ 'js/spec/views/pages/library_users_spec',
+ 'js/spec/views/modals/base_modal_spec',
+ 'js/spec/views/modals/edit_xblock_spec',
+ 'js/spec/views/modals/validation_error_modal_spec',
+ 'js/spec/views/settings/main_spec',
+ 'js/spec/factories/xblock_validation_spec',
+ 'js/certificates/spec/models/certificate_spec',
+ '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'
+ ];
+
+ i = 0;
+
+ while (i < testFiles.length) {
+ testFiles[i] = '/base/' + testFiles[i] + '.js';
+ i++;
+ }
+
+ specHelpers = [
+ 'common/js/spec_helpers/jasmine-extensions',
+ 'common/js/spec_helpers/jasmine-stealth',
+ 'common/js/spec_helpers/jasmine-waituntil'
+ ];
+
+ requireSerial(specHelpers.concat(testFiles), function() {
+ return window.__karma__.start();
+ });
+
+}).call(this, requirejs, requireSerial); // jshint ignore:line
diff --git a/cms/static/cms/js/spec/main_squire.js b/cms/static/cms/js/spec/main_squire.js
new file mode 100644
index 0000000000..8e65cef81b
--- /dev/null
+++ b/cms/static/cms/js/spec/main_squire.js
@@ -0,0 +1,218 @@
+(function(requirejs, requireSerial) {
+ 'use strict';
+
+ var i, specHelpers, testFiles;
+
+ requirejs.config({
+ baseUrl: '/base/',
+ paths: {
+ 'gettext': 'xmodule_js/common_static/js/test/i18n',
+ 'mustache': 'xmodule_js/common_static/js/vendor/mustache',
+ 'codemirror': 'xmodule_js/common_static/js/vendor/CodeMirror/codemirror',
+ 'jquery': 'common/js/vendor/jquery',
+ 'jquery-migrate': 'common/js/vendor/jquery-migrate',
+ 'jquery.ui': 'xmodule_js/common_static/js/vendor/jquery-ui.min',
+ 'jquery.form': 'xmodule_js/common_static/js/vendor/jquery.form',
+ 'jquery.markitup': 'xmodule_js/common_static/js/vendor/markitup/jquery.markitup',
+ 'jquery.leanModal': 'xmodule_js/common_static/js/vendor/jquery.leanModal',
+ 'jquery.smoothScroll': 'xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min',
+ 'jquery.scrollTo': 'common/js/vendor/jquery.scrollTo',
+ 'jquery.timepicker': 'xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker',
+ 'jquery.cookie': 'xmodule_js/common_static/js/vendor/jquery.cookie',
+ 'jquery.qtip': 'xmodule_js/common_static/js/vendor/jquery.qtip.min',
+ 'jquery.fileupload': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload',
+ 'jquery.fileupload-process': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process', // jshint ignore:line
+ 'jquery.fileupload-validate': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate', // jshint ignore:line
+ 'jquery.iframe-transport': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport', // jshint ignore:line
+ 'jquery.inputnumber': 'xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill',
+ 'jquery.immediateDescendents': 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents',
+ 'datepair': 'xmodule_js/common_static/js/vendor/timepicker/datepair',
+ 'date': 'xmodule_js/common_static/js/vendor/date',
+ 'text': 'xmodule_js/common_static/js/vendor/requirejs/text',
+ 'underscore': 'common/js/vendor/underscore',
+ 'underscore.string': 'common/js/vendor/underscore.string',
+ 'backbone': 'common/js/vendor/backbone',
+ 'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min',
+ 'backbone.paginator': 'common/js/vendor/backbone.paginator',
+ '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',
+ 'xblock/cms.runtime.v1': 'cms/js/xblock/cms.runtime.v1',
+ 'xblock': 'common/js/xblock',
+ 'utility': 'xmodule_js/common_static/js/src/utility',
+ 'sinon': 'xmodule_js/common_static/js/vendor/sinon-1.17.0',
+ 'squire': 'xmodule_js/common_static/js/vendor/Squire',
+ 'modernizr': 'edx-pattern-library/js/modernizr-custom',
+ 'afontgarde': 'edx-pattern-library/js/afontgarde',
+ 'edxicons': 'edx-pattern-library/js/edx-icons',
+ 'draggabilly': 'xmodule_js/common_static/js/vendor/draggabilly',
+ 'domReady': 'xmodule_js/common_static/js/vendor/domReady',
+ 'URI': 'xmodule_js/common_static/js/vendor/URI.min',
+ 'mathjax': '//cdn.mathjax.org/mathjax/2.6-latest/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // jshint ignore:line
+ 'youtube': '//www.youtube.com/player_api?noext',
+ 'coffee/src/ajax_prefix': 'xmodule_js/common_static/coffee/src/ajax_prefix'
+ },
+ 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.smoothScroll': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.smoothScroll'
+ },
+ 'jquery.scrollTo': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.scrollTo'
+ },
+ 'jquery.cookie': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.cookie'
+ },
+ 'jquery.qtip': {
+ deps: ['jquery'],
+ exports: 'jQuery.fn.qtip'
+ },
+ 'jquery.fileupload': {
+ deps: ['jquery.ui', 'jquery.iframe-transport'],
+ exports: 'jQuery.fn.fileupload'
+ },
+ 'jquery.fileupload-process': {
+ deps: ['jquery.fileupload']
+ },
+ 'jquery.fileupload-validate': {
+ deps: ['jquery.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.PageableCollection'
+ },
+ 'youtube': {
+ exports: 'YT'
+ },
+ 'codemirror': {
+ exports: 'CodeMirror'
+ },
+ 'tinymce': {
+ exports: 'tinymce'
+ },
+ 'mathjax': {
+ exports: 'MathJax',
+ init: function() {
+ window.MathJax.Hub.Config({
+ tex2jax: {
+ inlineMath: [['\\(', '\\)'], ['[mathjaxinline]', '[/mathjaxinline]']],
+ displayMath: [['\\[', '\\]'], ['[mathjax]', '[/mathjax]']]
+ }
+ });
+ window.MathJax.Hub.Configured();
+ }
+ },
+ 'URI': {
+ exports: 'URI'
+ },
+ 'xmodule': {
+ exports: 'XModule'
+ },
+ 'sinon': {
+ exports: 'sinon'
+ },
+ 'common/js/spec_helpers/jasmine-extensions': {
+ deps: ['jquery']
+ },
+ 'common/js/spec_helpers/jasmine-stealth': {
+ deps: ['underscore', 'underscore.string']
+ },
+ 'common/js/spec_helpers/jasmine-waituntil': {
+ 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/ajax_prefix': {
+ deps: ['jquery']
+ },
+ 'modernizr': {
+ exports: 'Modernizr'
+ },
+ 'afontgarde': {
+ exports: 'AFontGarde'
+ }
+ }
+ });
+
+ jasmine.getFixtures().fixturesPath += 'coffee/fixtures';
+
+ testFiles = [
+ 'coffee/spec/views/assets_spec',
+ 'js/spec/video/translations_editor_spec',
+ 'js/spec/video/file_uploader_editor_spec',
+ 'js/spec/models/group_configuration_spec'
+ ];
+
+ i = 0;
+
+ while (i < testFiles.length) {
+ testFiles[i] = '/base/' + testFiles[i] + '.js';
+ i++;
+ }
+
+ specHelpers = [
+ 'common/js/spec_helpers/jasmine-extensions',
+ 'common/js/spec_helpers/jasmine-stealth',
+ 'common/js/spec_helpers/jasmine-waituntil'
+ ];
+
+ requireSerial(specHelpers.concat(testFiles), function() {
+ return window.__karma__.start();
+ });
+
+}).call(this, requirejs, requireSerial); // jshint ignore:line
diff --git a/cms/static/js/spec/xblock/cms.runtime.v1_spec.js b/cms/static/cms/js/spec/xblock/cms.runtime.v1_spec.js
similarity index 79%
rename from cms/static/js/spec/xblock/cms.runtime.v1_spec.js
rename to cms/static/cms/js/spec/xblock/cms.runtime.v1_spec.js
index c508c21548..893fe6827a 100644
--- a/cms/static/js/spec/xblock/cms.runtime.v1_spec.js
+++ b/cms/static/cms/js/spec/xblock/cms.runtime.v1_spec.js
@@ -1,10 +1,11 @@
-define(["js/spec_helpers/edit_helpers", "js/views/modals/base_modal", "xblock/cms.runtime.v1"],
- function (EditHelpers, BaseModal) {
+define(['js/spec_helpers/edit_helpers', 'js/views/modals/base_modal', 'xblock/cms.runtime.v1'],
+ function(EditHelpers, BaseModal) {
+ 'use strict';
- describe("Studio Runtime v1", function() {
+ describe('Studio Runtime v1', function() {
var runtime;
- beforeEach(function () {
+ beforeEach(function() {
EditHelpers.installEditTemplates();
runtime = new window.StudioRuntime.v1();
});
@@ -20,7 +21,7 @@ define(["js/spec_helpers/edit_helpers", "js/views/modals/base_modal", "xblock/cm
});
it('shows save notifications', function() {
- var title = "Mock saving...",
+ var title = 'Mock saving...',
notificationSpy = EditHelpers.createNotificationSpy();
runtime.notify('save', {
state: 'start',
@@ -34,9 +35,9 @@ define(["js/spec_helpers/edit_helpers", "js/views/modals/base_modal", "xblock/cm
});
it('shows error messages', function() {
- var title = "Mock Error",
- message = "This is a mock error.",
- notificationSpy = EditHelpers.createNotificationSpy("Error");
+ var title = 'Mock Error',
+ message = 'This is a mock error.',
+ notificationSpy = EditHelpers.createNotificationSpy('Error');
runtime.notify('error', {
title: title,
message: message
@@ -44,7 +45,7 @@ define(["js/spec_helpers/edit_helpers", "js/views/modals/base_modal", "xblock/cm
EditHelpers.verifyNotificationShowing(notificationSpy, title);
});
- describe("Modal Dialogs", function() {
+ describe('Modal Dialogs', function() {
var MockModal, modal, showMockModal;
MockModal = BaseModal.extend({
@@ -55,12 +56,12 @@ define(["js/spec_helpers/edit_helpers", "js/views/modals/base_modal", "xblock/cm
showMockModal = function() {
modal = new MockModal({
- title: "Mock Modal"
+ title: 'Mock Modal'
});
modal.show();
};
- beforeEach(function () {
+ beforeEach(function() {
EditHelpers.installEditTemplates();
});
@@ -68,7 +69,7 @@ define(["js/spec_helpers/edit_helpers", "js/views/modals/base_modal", "xblock/cm
EditHelpers.hideModalIfShowing(modal);
});
- it('cancels a modal dialog', function () {
+ it('cancels a modal dialog', function() {
showMockModal();
runtime.notify('modal-shown', modal);
expect(EditHelpers.isShowingModal(modal)).toBeTruthy();
diff --git a/cms/static/cms/js/xblock/cms.runtime.v1.js b/cms/static/cms/js/xblock/cms.runtime.v1.js
new file mode 100644
index 0000000000..6c5e73aaf3
--- /dev/null
+++ b/cms/static/cms/js/xblock/cms.runtime.v1.js
@@ -0,0 +1,188 @@
+define(['jquery', 'backbone', 'xblock/runtime.v1', 'URI', 'gettext', 'js/utils/modal',
+ 'common/js/components/views/feedback_notification'],
+ function($, Backbone, XBlock, URI, gettext, ModalUtils, NotificationView) {
+ 'use strict';
+
+ var __hasProp = {}.hasOwnProperty,
+ __extends = function(child, parent) {
+ var key;
+ for (key in parent) {
+ if (__hasProp.call(parent, key)) {
+ child[key] = parent[key];
+ }
+ }
+ function Ctor() {
+ this.constructor = child;
+ }
+ Ctor.prototype = parent.prototype;
+ child.prototype = new Ctor();
+ child.__super__ = parent.prototype;
+ return child;
+ },
+ BaseRuntime = {},
+ PreviewRuntime = {},
+ StudioRuntime = {};
+
+ BaseRuntime.v1 = (function(_super) {
+
+ __extends(v1, _super);
+
+ v1.prototype.handlerUrl = function(element, handlerName, suffix, query) {
+ var uri;
+ uri = URI(this.handlerPrefix)
+ .segment($(element).data('usage-id'))
+ .segment('handler')
+ .segment(handlerName);
+ if (suffix !== null) {
+ uri.segment(suffix);
+ }
+ if (query !== null) {
+ uri.search(query);
+ }
+ return uri.toString();
+ };
+
+ function v1() {
+ v1.__super__.constructor.call(this);
+ this.dispatcher = _.clone(Backbone.Events);
+ this.listenTo('save', this._handleSave);
+ this.listenTo('cancel', this._handleCancel);
+ this.listenTo('error', this._handleError);
+ this.listenTo('modal-shown', function(data) {
+ this.modal = data;
+ });
+ this.listenTo('modal-hidden', function() {
+ this.modal = null;
+ });
+ this.listenTo('page-shown', function(data) {
+ this.page = data;
+ });
+ }
+
+ /**
+ * Notify the Studio client-side runtime of an event so that it
+ * can update the UI in a consistent way.
+ *
+ * @param {string} name The name of the event.
+ * @param {object} data A JSON representation of the data to be included with the event.
+ */
+ v1.prototype.notify = function(name, data) {
+ this.dispatcher.trigger(name, data);
+ };
+
+ /**
+ * Listen to a Studio event and invoke the specified callback when it is triggered.
+ *
+ * @param {string} name The name of the event.
+ * @param {function} callback The callback to be invoked.
+ */
+ v1.prototype.listenTo = function(name, callback) {
+ this.dispatcher.bind(name, callback, this);
+ };
+
+ /**
+ * Refresh the view for the xblock represented by the specified element.
+ *
+ * @param {element} element The element representing the XBlock.
+ */
+ v1.prototype.refreshXBlock = function(element) {
+ if (this.page) {
+ this.page.refreshXBlock(element);
+ }
+ };
+
+ v1.prototype._handleError = function(data) {
+ var message, title;
+ message = data.message || data.msg;
+ if (message) {
+ // TODO: remove 'Open Assessment' specific default title
+ title = data.title || gettext('OpenAssessment Save Error');
+ this.alert = new NotificationView.Error({
+ title: title,
+ message: message,
+ closeIcon: false,
+ shown: false
+ });
+ this.alert.show();
+ }
+ };
+
+ v1.prototype._handleSave = function(data) {
+ var message;
+ // Starting to save, so show a notification
+ if (data.state === 'start') {
+ message = data.message || gettext('Saving');
+ this.notification = new NotificationView.Mini({
+ title: message
+ });
+ this.notification.show();
+ } else if (data.state === 'end') {
+ // Finished saving, so hide the notification and refresh appropriately
+ this._hideAlerts();
+
+ if (this.modal && this.modal.onSave) {
+ // Notify the modal that the save has completed so that it can hide itself
+ // and then refresh the xblock.
+ this.modal.onSave();
+ } else if (data.element) {
+ // ... else ask it to refresh the newly saved xblock
+ this.refreshXBlock(data.element);
+ }
+ this.notification.hide();
+ }
+ };
+
+ v1.prototype._handleCancel = function() {
+ this._hideAlerts();
+ if (this.modal) {
+ this.modal.cancel();
+ this.notify('modal-hidden');
+ }
+ };
+
+ /**
+ * Hide any alerts that are being shown.
+ */
+ v1.prototype._hideAlerts = function() {
+ if (this.alert && this.alert.options.shown) {
+ this.alert.hide();
+ }
+ };
+
+ return v1;
+
+ })(XBlock.Runtime.v1);
+
+ PreviewRuntime.v1 = (function(_super) {
+
+ __extends(v1, _super);
+
+ function v1() {
+ return v1.__super__.constructor.apply(this, arguments);
+ }
+
+ v1.prototype.handlerPrefix = '/preview/xblock';
+
+ return v1;
+
+ })(BaseRuntime.v1);
+
+ StudioRuntime.v1 = (function(_super) {
+
+ __extends(v1, _super);
+
+ function v1() {
+ return v1.__super__.constructor.apply(this, arguments);
+ }
+
+ v1.prototype.handlerPrefix = '/xblock';
+
+ return v1;
+
+ })(BaseRuntime.v1);
+
+ // Install the runtime's into the global namespace
+ window.BaseRuntime = BaseRuntime;
+ window.PreviewRuntime = PreviewRuntime;
+ window.StudioRuntime = StudioRuntime;
+ });
diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee
deleted file mode 100644
index 3685cf47ac..0000000000
--- a/cms/static/coffee/spec/main.coffee
+++ /dev/null
@@ -1,299 +0,0 @@
-requirejs.config({
- baseUrl: '/base/',
- paths: {
- "gettext": "xmodule_js/common_static/js/test/i18n",
- "mustache": "xmodule_js/common_static/js/vendor/mustache",
- "codemirror": "xmodule_js/common_static/js/vendor/CodeMirror/codemirror",
- "jquery": "xmodule_js/common_static/common/js/vendor/jquery",
- "jquery-migrate": "xmodule_js/common_static/common/js/vendor/jquery-migrate",
- "jquery.ui": "xmodule_js/common_static/js/vendor/jquery-ui.min",
- "jquery.form": "xmodule_js/common_static/js/vendor/jquery.form",
- "jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup",
- "jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal",
- "jquery.ajaxQueue": "xmodule_js/common_static/js/vendor/jquery.ajaxQueue",
- "jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min",
- "jquery.scrollTo": "common/js/vendor/jquery.scrollTo",
- "jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker",
- "jquery.cookie": "xmodule_js/common_static/js/vendor/jquery.cookie",
- "jquery.qtip": "xmodule_js/common_static/js/vendor/jquery.qtip.min",
- "jquery.fileupload": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload",
- "jquery.fileupload-process": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process",
- "jquery.fileupload-validate": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate",
- "jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
- "jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
- "jquery.immediateDescendents": "xmodule_js/common_static/coffee/src/jquery.immediateDescendents",
- "jquery.simulate": "xmodule_js/common_static/js/vendor/jquery.simulate",
- "datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair",
- "date": "xmodule_js/common_static/js/vendor/date",
- "moment": "xmodule_js/common_static/js/vendor/moment.min",
- "moment-with-locales": "xmodule_js/common_static/js/vendor/moment-with-locales.min",
- "text": "xmodule_js/common_static/js/vendor/requirejs/text",
- "underscore": "common/js/vendor/underscore",
- "underscore.string": "common/js/vendor/underscore.string",
- "backbone": "common/js/vendor/backbone",
- "backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
- "backbone.paginator": "common/js/vendor/backbone.paginator",
- "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",
- "xmodule": "xmodule_js/src/xmodule",
- "xblock/cms.runtime.v1": "coffee/src/xblock/cms.runtime.v1",
- "xblock/core": "xmodule_js/common_static/js/xblock/core",
- "xblock": "xmodule_js/common_static/coffee/src/xblock",
- "utility": "xmodule_js/common_static/js/src/utility",
- "accessibility": "xmodule_js/common_static/js/src/accessibility_tools",
- "sinon": "xmodule_js/common_static/js/vendor/sinon-1.17.0",
- "squire": "xmodule_js/common_static/js/vendor/Squire",
- "jasmine-imagediff": "xmodule_js/common_static/js/vendor/jasmine-imagediff",
- "draggabilly": "xmodule_js/common_static/js/vendor/draggabilly",
- "domReady": "xmodule_js/common_static/js/vendor/domReady",
- "URI": "xmodule_js/common_static/js/vendor/URI.min",
- "mock-ajax": "xmodule_js/common_static/js/vendor/mock-ajax",
- "modernizr": "edx-pattern-library/js/modernizr-custom",
- "afontgarde": "edx-pattern-library/js/afontgarde",
- "edxicons": "edx-pattern-library/js/edx-icons",
-
- "mathjax": "//cdn.mathjax.org/mathjax/2.6-latest/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured",
- "youtube": "//www.youtube.com/player_api?noext",
-
- "coffee/src/ajax_prefix": "xmodule_js/common_static/coffee/src/ajax_prefix",
- "js/spec/test_utils": "js/spec/test_utils",
- }
- shim: {
- "gettext": {
- exports: "gettext"
- },
- "date": {
- exports: "Date"
- },
- "jquery-migrate": ['jquery'],
- "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.smoothScroll": {
- deps: ["jquery"],
- exports: "jQuery.fn.smoothScroll"
- },
- "jquery.ajaxQueue": {
- deps: ["jquery"],
- exports: "jQuery.fn.ajaxQueue"
- },
- "jquery.scrollTo": {
- deps: ["jquery"],
- exports: "jQuery.fn.scrollTo"
- },
- "jquery.cookie": {
- deps: ["jquery"],
- exports: "jQuery.fn.cookie"
- },
- "jquery.qtip": {
- deps: ["jquery"],
- exports: "jQuery.fn.qtip"
- },
- "jquery.fileupload": {
- deps: ["jquery.ui", "jquery.iframe-transport"],
- exports: "jQuery.fn.fileupload"
- },
- "jquery.fileupload-process": {
- deps: ["jquery.fileupload"]
- },
- "jquery.fileupload-validate": {
- deps: ["jquery.fileupload"]
- },
- "jquery.inputnumber": {
- deps: ["jquery"],
- exports: "jQuery.fn.inputNumber"
- },
- "jquery.simulate": {
- deps: ["jquery"],
- exports: "jQuery.fn.simulate"
- },
- "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.PageableCollection"
- },
- "backbone-relational": {
- deps: ["backbone"],
- },
- "youtube": {
- exports: "YT"
- },
- "codemirror": {
- exports: "CodeMirror"
- },
- "tinymce": {
- exports: "tinymce"
- },
- "mathjax": {
- exports: "MathJax",
- init: ->
- MathJax.Hub.Config
- tex2jax:
- inlineMath: [
- ["\\(", "\\)"],
- ['[mathjaxinline]', '[/mathjaxinline]']
- ]
- displayMath: [
- ["\\[", "\\]"],
- ['[mathjax]', '[/mathjax]']
- ]
- MathJax.Hub.Configured()
- },
- "URI": {
- exports: "URI"
- },
- "xmodule": {
- exports: "XModule"
- },
- "sinon": {
- exports: "sinon"
- },
- "jasmine-imagediff": {},
- "common/js/spec_helpers/jasmine-extensions": {
- deps: ["jquery"]
- },
- "common/js/spec_helpers/jasmine-stealth": {
- deps: ["underscore", "underscore.string"]
- },
- "common/js/spec_helpers/jasmine-waituntil": {
- deps: ["jquery"]
- },
- "xblock/core": {
- exports: "XBlock",
- deps: ["jquery", "jquery.immediateDescendents"]
- },
- "xblock/runtime.v1": {
- exports: "XBlock",
- deps: ["xblock/core"]
- },
- "mock-ajax": {
- deps: ["jquery"]
- }
-
- "coffee/src/main": {
- deps: ["coffee/src/ajax_prefix"]
- },
- "coffee/src/ajax_prefix": {
- deps: ["jquery"]
- },
- "modernizr": {
- exports: "Modernizr"
- },
- "afontgarde": {
- exports: "AFontGarde"
- }
- }
-});
-
-jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
-
-testFiles = [
- "coffee/spec/main_spec",
- "coffee/spec/models/course_spec",
- "coffee/spec/models/metadata_spec",
- "coffee/spec/models/section_spec",
- "coffee/spec/models/settings_course_grader_spec",
- "coffee/spec/models/settings_grading_spec",
- "coffee/spec/models/textbook_spec",
- "coffee/spec/models/upload_spec",
- "coffee/spec/views/course_info_spec",
- "coffee/spec/views/metadata_edit_spec",
- "coffee/spec/views/module_edit_spec",
- "coffee/spec/views/textbook_spec",
- "coffee/spec/views/upload_spec",
- "js/spec/video/transcripts/utils_spec",
- "js/spec/video/transcripts/editor_spec",
- "js/spec/video/transcripts/videolist_spec",
- "js/spec/video/transcripts/message_manager_spec",
- "js/spec/video/transcripts/file_uploader_spec",
- "js/spec/models/component_template_spec",
- "js/spec/models/explicit_url_spec",
- "js/spec/models/xblock_info_spec",
- "js/spec/models/xblock_validation_spec",
- "js/spec/models/license_spec",
- "js/spec/utils/drag_and_drop_spec",
- "js/spec/utils/handle_iframe_binding_spec",
- "js/spec/utils/module_spec",
- "js/spec/views/active_video_upload_list_spec",
- "js/spec/views/previous_video_upload_spec",
- "js/spec/views/previous_video_upload_list_spec",
- "js/spec/views/assets_spec",
- "js/spec/views/baseview_spec",
- "js/spec/views/container_spec",
- "js/spec/views/paged_container_spec",
- "js/spec/views/group_configuration_spec",
- "js/spec/views/unit_outline_spec",
- "js/spec/views/xblock_spec",
- "js/spec/views/xblock_editor_spec",
- "js/spec/views/xblock_string_field_editor_spec",
- "js/spec/views/xblock_validation_spec",
- "js/spec/views/license_spec",
- "js/spec/views/paging_spec",
- "js/spec/views/login_studio_spec",
- "js/spec/views/pages/container_spec",
- "js/spec/views/pages/container_subviews_spec",
- "js/spec/views/pages/group_configurations_spec",
- "js/spec/views/pages/course_outline_spec",
- "js/spec/views/pages/course_rerun_spec",
- "js/spec/views/pages/index_spec",
- "js/spec/views/pages/library_users_spec",
- "js/spec/views/modals/base_modal_spec",
- "js/spec/views/modals/edit_xblock_spec",
- "js/spec/views/modals/validation_error_modal_spec",
- "js/spec/views/settings/main_spec",
- "js/spec/factories/xblock_validation_spec",
- "js/spec/xblock/cms.runtime.v1_spec",
- "js/certificates/spec/models/certificate_spec",
- "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"
-]
-
-i = 0
-while i < testFiles.length
- testFiles[i] = '/base/' + testFiles[i] + '.js'
- i++
-
-specHelpers = [
- 'common/js/spec_helpers/jasmine-extensions',
- 'common/js/spec_helpers/jasmine-stealth',
- 'common/js/spec_helpers/jasmine-waituntil'
-]
-
-# Jasmine has a global stack for creating a tree of specs. We need to load
-# spec files one by one, otherwise some end up getting nested under others.
-requireSerial specHelpers.concat(testFiles), ->
-# start test run, once Require.js is done
- window.__karma__.start()
diff --git a/cms/static/coffee/spec/main_spec.coffee b/cms/static/coffee/spec/main_spec.coffee
index ed6b6b6d6e..3875df0de5 100644
--- a/cms/static/coffee/spec/main_spec.coffee
+++ b/cms/static/coffee/spec/main_spec.coffee
@@ -1,4 +1,4 @@
-require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_helpers", "jquery.cookie"],
+require ["jquery", "backbone", "coffee/src/main", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "jquery.cookie"],
($, Backbone, main, AjaxHelpers) ->
describe "CMS", ->
it "should initialize URL", ->
diff --git a/cms/static/coffee/spec/main_squire.coffee b/cms/static/coffee/spec/main_squire.coffee
deleted file mode 100644
index 3743e9602d..0000000000
--- a/cms/static/coffee/spec/main_squire.coffee
+++ /dev/null
@@ -1,218 +0,0 @@
-requirejs.config({
- baseUrl: '/base/',
-
- paths: {
- "gettext": "xmodule_js/common_static/js/test/i18n",
- "mustache": "xmodule_js/common_static/js/vendor/mustache",
- "codemirror": "xmodule_js/common_static/js/vendor/CodeMirror/codemirror",
- "jquery": "common/js/vendor/jquery",
- "jquery-migrate": "common/js/vendor/jquery-migrate",
- "jquery.ui": "xmodule_js/common_static/js/vendor/jquery-ui.min",
- "jquery.form": "xmodule_js/common_static/js/vendor/jquery.form",
- "jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup",
- "jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal",
- "jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min",
- "jquery.scrollTo": "common/js/vendor/jquery.scrollTo",
- "jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker",
- "jquery.cookie": "xmodule_js/common_static/js/vendor/jquery.cookie",
- "jquery.qtip": "xmodule_js/common_static/js/vendor/jquery.qtip.min",
- "jquery.fileupload": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload",
- "jquery.fileupload-process": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process",
- "jquery.fileupload-validate": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate",
- "jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
- "jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
- "jquery.immediateDescendents": "xmodule_js/common_static/coffee/src/jquery.immediateDescendents",
- "datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair",
- "date": "xmodule_js/common_static/js/vendor/date",
- "text": "xmodule_js/common_static/js/vendor/requirejs/text",
- "underscore": "common/js/vendor/underscore",
- "underscore.string": "common/js/vendor/underscore.string",
- "backbone": "common/js/vendor/backbone",
- "backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
- "backbone.paginator": "common/js/vendor/backbone.paginator",
- "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",
- "xblock/cms.runtime.v1": "coffee/src/xblock/cms.runtime.v1",
- "xblock/core": "xmodule_js/common_static/js/xblock/core",
- "xblock": "xmodule_js/common_static/coffee/src/xblock",
- "utility": "xmodule_js/common_static/js/src/utility",
- "sinon": "xmodule_js/common_static/js/vendor/sinon-1.17.0",
- "squire": "xmodule_js/common_static/js/vendor/Squire",
- "modernizr": "edx-pattern-library/js/modernizr-custom",
- "afontgarde": "edx-pattern-library/js/afontgarde",
- "edxicons": "edx-pattern-library/js/edx-icons",
- "draggabilly": "xmodule_js/common_static/js/vendor/draggabilly",
- "domReady": "xmodule_js/common_static/js/vendor/domReady",
- "URI": "xmodule_js/common_static/js/vendor/URI.min",
-
- "mathjax": "//cdn.mathjax.org/mathjax/2.6-latest/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured",
- "youtube": "//www.youtube.com/player_api?noext",
-
- "coffee/src/ajax_prefix": "xmodule_js/common_static/coffee/src/ajax_prefix"
- }
- 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.smoothScroll": {
- deps: ["jquery"],
- exports: "jQuery.fn.smoothScroll"
- },
- "jquery.scrollTo": {
- deps: ["jquery"],
- exports: "jQuery.fn.scrollTo"
- },
- "jquery.cookie": {
- deps: ["jquery"],
- exports: "jQuery.fn.cookie"
- },
- "jquery.qtip": {
- deps: ["jquery"],
- exports: "jQuery.fn.qtip"
- },
- "jquery.fileupload": {
- deps: ["jquery.ui", "jquery.iframe-transport"],
- exports: "jQuery.fn.fileupload"
- },
- "jquery.fileupload-process": {
- deps: ["jquery.fileupload"]
- },
- "jquery.fileupload-validate": {
- deps: ["jquery.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.PageableCollection"
- },
- "youtube": {
- exports: "YT"
- },
- "codemirror": {
- exports: "CodeMirror"
- },
- "tinymce": {
- exports: "tinymce"
- },
- "mathjax": {
- exports: "MathJax",
- init: ->
- MathJax.Hub.Config
- tex2jax:
- inlineMath: [
- ["\\(","\\)"],
- ['[mathjaxinline]','[/mathjaxinline]']
- ]
- displayMath: [
- ["\\[","\\]"],
- ['[mathjax]','[/mathjax]']
- ]
- MathJax.Hub.Configured();
- },
- "URI": {
- exports: "URI"
- },
- "xmodule": {
- exports: "XModule"
- },
- "sinon": {
- exports: "sinon"
- },
- "common/js/spec_helpers/jasmine-extensions": {
- deps: ["jquery"]
- },
- "common/js/spec_helpers/jasmine-stealth": {
- deps: ["underscore", "underscore.string"]
- },
- "common/js/spec_helpers/jasmine-waituntil": {
- 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/ajax_prefix": {
- deps: ["jquery"]
- },
- "modernizr": {
- exports: "Modernizr"
- },
- "afontgarde": {
- exports: "AFontGarde"
- }
- }
-});
-
-jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
-
-testFiles = [
- 'coffee/spec/views/assets_spec',
- 'js/spec/video/translations_editor_spec',
- 'js/spec/video/file_uploader_editor_spec',
- 'js/spec/models/group_configuration_spec'
-]
-i = 0
-while i < testFiles.length
- testFiles[i] = '/base/' + testFiles[i] + '.js'
- i++
-
-specHelpers = [
- 'common/js/spec_helpers/jasmine-extensions',
- 'common/js/spec_helpers/jasmine-stealth',
- 'common/js/spec_helpers/jasmine-waituntil'
-]
-
-# Jasmine has a global stack for creating a tree of specs. We need to load
-# spec files one by one, otherwise some end up getting nested under others.
-requireSerial specHelpers.concat(testFiles), ->
-# start test run, once Require.js is done
- window.__karma__.start()
diff --git a/cms/static/coffee/spec/models/section_spec.coffee b/cms/static/coffee/spec/models/section_spec.coffee
index 8e7438f5ba..8a954b50dc 100644
--- a/cms/static/coffee/spec/models/section_spec.coffee
+++ b/cms/static/coffee/spec/models/section_spec.coffee
@@ -1,4 +1,4 @@
-define ["js/models/section", "common/js/spec_helpers/ajax_helpers", "js/utils/module"], (Section, AjaxHelpers, ModuleUtils) ->
+define ["js/models/section", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "js/utils/module"], (Section, AjaxHelpers, ModuleUtils) ->
describe "Section", ->
describe "basic", ->
beforeEach ->
diff --git a/cms/static/coffee/spec/views/assets_spec.coffee b/cms/static/coffee/spec/views/assets_spec.coffee
index eea73055c5..e94d166a52 100644
--- a/cms/static/coffee/spec/views/assets_spec.coffee
+++ b/cms/static/coffee/spec/views/assets_spec.coffee
@@ -1,4 +1,4 @@
-define ["jquery", "common/js/spec_helpers/ajax_helpers", "squire"],
+define ["jquery", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "squire"],
($, AjaxHelpers, Squire) ->
assetLibraryTpl = readFixtures('asset-library.underscore')
diff --git a/cms/static/coffee/spec/views/course_info_spec.coffee b/cms/static/coffee/spec/views/course_info_spec.coffee
index edcf9bf7a7..6e275ca2f7 100644
--- a/cms/static/coffee/spec/views/course_info_spec.coffee
+++ b/cms/static/coffee/spec/views/course_info_spec.coffee
@@ -1,4 +1,5 @@
-define ["js/views/course_info_handout", "js/views/course_info_update", "js/models/module_info", "js/collections/course_update", "common/js/spec_helpers/ajax_helpers"],
+define ["js/views/course_info_handout", "js/views/course_info_update", "js/models/module_info",
+ "js/collections/course_update", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers"],
(CourseInfoHandoutsView, CourseInfoUpdateView, ModuleInfo, CourseUpdateCollection, AjaxHelpers) ->
describe "Course Updates and Handouts", ->
diff --git a/cms/static/coffee/spec/views/module_edit_spec.coffee b/cms/static/coffee/spec/views/module_edit_spec.coffee
deleted file mode 100644
index a631c4ddde..0000000000
--- a/cms/static/coffee/spec/views/module_edit_spec.coffee
+++ /dev/null
@@ -1,180 +0,0 @@
-define ["jquery", "common/js/components/utils/view_utils", "js/spec_helpers/edit_helpers",
- "coffee/src/views/module_edit", "js/models/module_info", "xmodule"],
- ($, ViewUtils, edit_helpers, ModuleEdit, ModuleModel) ->
-
- describe "ModuleEdit", ->
- beforeEach ->
- @stubModule = new ModuleModel
- id: "stub-id"
-
- setFixtures """
-
- """
- edit_helpers.installEditTemplates(true);
- spyOn($, 'ajax').and.returnValue(@moduleData)
-
- @moduleEdit = new ModuleEdit(
- el: $(".component")
- model: @stubModule
- onDelete: jasmine.createSpy()
- )
-
- describe "class definition", ->
- it "sets the correct tagName", ->
- expect(@moduleEdit.tagName).toEqual("li")
-
- it "sets the correct className", ->
- expect(@moduleEdit.className).toEqual("component")
-
- describe "methods", ->
- describe "initialize", ->
- beforeEach ->
- spyOn(ModuleEdit.prototype, 'render')
- @moduleEdit = new ModuleEdit(
- el: $(".component")
- model: @stubModule
- onDelete: jasmine.createSpy()
- )
-
- it "renders the module editor", ->
- expect(ModuleEdit.prototype.render).toHaveBeenCalled()
-
- describe "render", ->
- beforeEach ->
- spyOn(@moduleEdit, 'loadDisplay')
- spyOn(@moduleEdit, 'delegateEvents')
- spyOn($.fn, 'append')
- spyOn(ViewUtils, 'loadJavaScript').and.returnValue($.Deferred().resolve().promise());
-
- window.MockXBlock = (runtime, element) ->
- return { }
-
- window.loadedXBlockResources = undefined
-
- @moduleEdit.render()
- $.ajax.calls.mostRecent().args[0].success(
- html: 'Response html
'
- resources: [
- ['hash1', {kind: 'text', mimetype: 'text/css', data: 'inline-css'}],
- ['hash2', {kind: 'url', mimetype: 'text/css', data: 'css-url'}],
- ['hash3', {kind: 'text', mimetype: 'application/javascript', data: 'inline-js'}],
- ['hash4', {kind: 'url', mimetype: 'application/javascript', data: 'js-url'}],
- ['hash5', {placement: 'head', mimetype: 'text/html', data: 'head-html'}],
- ['hash6', {placement: 'not-head', mimetype: 'text/html', data: 'not-head-html'}],
- ]
- )
-
- afterEach ->
- window.MockXBlock = null
-
- it "loads the module preview via ajax on the view element", ->
- expect($.ajax).toHaveBeenCalledWith(
- url: "/xblock/#{@moduleEdit.model.id}/student_view"
- type: "GET"
- cache: false
- headers:
- Accept: 'application/json'
- success: jasmine.any(Function)
- )
-
- expect($.ajax).not.toHaveBeenCalledWith(
- url: "/xblock/#{@moduleEdit.model.id}/studio_view"
- type: "GET"
- headers:
- Accept: 'application/json'
- success: jasmine.any(Function)
- )
- expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
- expect(@moduleEdit.delegateEvents).toHaveBeenCalled()
-
- it "loads the editing view via ajax on demand", ->
- edit_helpers.installEditTemplates(true);
- expect($.ajax).not.toHaveBeenCalledWith(
- url: "/xblock/#{@moduleEdit.model.id}/studio_view"
- type: "GET"
- cache : false
- headers:
- Accept: 'application/json'
- success: jasmine.any(Function)
- )
-
- @moduleEdit.clickEditButton({'preventDefault': jasmine.createSpy('event.preventDefault')})
-
- mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore')
-
- $.ajax.calls.mostRecent().args[0].success(
- html: mockXBlockEditorHtml
- resources: [
- ['hash1', {kind: 'text', mimetype: 'text/css', data: 'inline-css'}],
- ['hash2', {kind: 'url', mimetype: 'text/css', data: 'css-url'}],
- ['hash3', {kind: 'text', mimetype: 'application/javascript', data: 'inline-js'}],
- ['hash4', {kind: 'url', mimetype: 'application/javascript', data: 'js-url'}],
- ['hash5', {placement: 'head', mimetype: 'text/html', data: 'head-html'}],
- ['hash6', {placement: 'not-head', mimetype: 'text/html', data: 'not-head-html'}],
- ]
- )
-
- expect($.ajax).toHaveBeenCalledWith(
- url: "/xblock/#{@moduleEdit.model.id}/studio_view"
- type: "GET"
- cache: false
- headers:
- Accept: 'application/json'
- success: jasmine.any(Function)
- )
- expect(@moduleEdit.delegateEvents).toHaveBeenCalled()
-
- it "loads inline css from fragments", ->
- expect($('head').append).toHaveBeenCalledWith("")
-
- it "loads css urls from fragments", ->
- expect($('head').append).toHaveBeenCalledWith("")
-
- it "loads inline js from fragments", ->
- expect($('head').append).toHaveBeenCalledWith("")
-
- it "loads js urls from fragments", ->
- expect(ViewUtils.loadJavaScript).toHaveBeenCalledWith("js-url")
-
- it "loads head html", ->
- expect($('head').append).toHaveBeenCalledWith("head-html")
-
- it "doesn't load body html", ->
- expect($.fn.append).not.toHaveBeenCalledWith('not-head-html')
-
- it "doesn't reload resources", ->
- count = $('head').append.calls.count()
- $.ajax.calls.mostRecent().args[0].success(
- html: 'Response html 2
'
- resources: [
- ['hash1', {kind: 'text', mimetype: 'text/css', data: 'inline-css'}],
- ]
- )
- expect($('head').append.calls.count()).toBe(count)
-
- describe "loadDisplay", ->
- beforeEach ->
- spyOn(XBlock, 'initializeBlock')
- @moduleEdit.loadDisplay()
-
- it "loads the .xmodule-display inside the module editor", ->
- expect(XBlock.initializeBlock).toHaveBeenCalled()
- expect(XBlock.initializeBlock.calls.mostRecent().args[0].get(0)).toBe($('.xblock-student_view').get(0))
diff --git a/cms/static/coffee/spec/views/textbook_spec.coffee b/cms/static/coffee/spec/views/textbook_spec.coffee
index bc855b621d..b4cbdcbf70 100644
--- a/cms/static/coffee/spec/views/textbook_spec.coffee
+++ b/cms/static/coffee/spec/views/textbook_spec.coffee
@@ -1,339 +1,341 @@
define ["js/models/textbook", "js/models/chapter", "js/collections/chapter", "js/models/course",
"js/collections/textbook", "js/views/show_textbook", "js/views/edit_textbook", "js/views/list_textbooks",
"js/views/edit_chapter", "common/js/components/views/feedback_prompt",
- "common/js/components/views/feedback_notification", "common/js/components/utils/view_utils","common/js/spec_helpers/ajax_helpers",
+ "common/js/components/views/feedback_notification", "common/js/components/utils/view_utils",
+ "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers",
"js/spec_helpers/modal_helpers"],
-(Textbook, Chapter, ChapterSet, Course, TextbookSet, ShowTextbook, EditTextbook, ListTextbooks, EditChapter, Prompt, Notification, ViewUtils, AjaxHelpers, modal_helpers) ->
+ (Textbook, Chapter, ChapterSet, Course, TextbookSet, ShowTextbook, EditTextbook, ListTextbooks, EditChapter,
+ Prompt, Notification, ViewUtils, AjaxHelpers, modal_helpers) ->
- describe "ShowTextbook", ->
- tpl = readFixtures('show-textbook.underscore')
-
- beforeEach ->
- setFixtures($("');
+ });
+ it('loads js urls from fragments', function() {
+ return expect(ViewUtils.loadJavaScript).toHaveBeenCalledWith('js-url');
+ });
+ it('loads head html', function() {
+ return expect($('head').append).toHaveBeenCalledWith('head-html');
+ });
+ it("doesn't load body html", function() {
+ return expect($.fn.append).not.toHaveBeenCalledWith("not-head-html");
+ });
+ it("doesn't reload resources", function() {
+ var count;
+ count = $('head').append.calls.count();
+ $.ajax.calls.mostRecent().args[0].success({
+ html: 'Response html 2
',
+ resources: [
+ [
+ 'hash1', {
+ kind: 'text',
+ mimetype: 'text/css',
+ data: 'inline-css'
+ }
+ ]
+ ]
+ });
+ return expect($('head').append.calls.count()).toBe(count);
+ });
+ });
+ describe('loadDisplay', function() {
+ beforeEach(function() {
+ spyOn(XBlock, 'initializeBlock');
+ return this.moduleEdit.loadDisplay();
+ });
+ it('loads the .xmodule-display inside the module editor', function() {
+ expect(XBlock.initializeBlock).toHaveBeenCalled();
+ var sel = '.xblock-student_view';
+ return expect(XBlock.initializeBlock.calls.mostRecent().args[0].get(0)).toBe($(sel).get(0));
+ });
+ });
+ });
+ });
+ });
+}).call(this);
diff --git a/cms/static/js/spec/views/paged_container_spec.js b/cms/static/js/spec/views/paged_container_spec.js
index 653e283fbb..fd2e210bde 100644
--- a/cms/static/js/spec/views/paged_container_spec.js
+++ b/cms/static/js/spec/views/paged_container_spec.js
@@ -1,4 +1,4 @@
-define(["jquery", "underscore", "common/js/spec_helpers/ajax_helpers", "URI", "js/models/xblock_info",
+define(["jquery", "underscore", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "URI", "js/models/xblock_info",
"js/views/paged_container", "js/views/paging_header",
"common/js/components/views/paging_footer", "js/views/xblock"],
function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingHeader, PagingFooter, XBlockView) {
diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js
index b307111536..9d6ec66a11 100644
--- a/cms/static/js/spec/views/pages/container_spec.js
+++ b/cms/static/js/spec/views/pages/container_spec.js
@@ -1,4 +1,4 @@
-define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/ajax_helpers",
+define(["jquery", "underscore", "underscore.string", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers",
"common/js/spec_helpers/template_helpers", "js/spec_helpers/edit_helpers",
"js/views/pages/container", "js/views/pages/paged_container", "js/models/xblock_info", "jquery.simulate"],
function ($, _, str, AjaxHelpers, TemplateHelpers, EditHelpers, ContainerPage, PagedContainerPage, XBlockInfo) {
diff --git a/cms/static/js/spec/views/pages/container_subviews_spec.js b/cms/static/js/spec/views/pages/container_subviews_spec.js
index 1f518342cc..22af2fb667 100644
--- a/cms/static/js/spec/views/pages/container_subviews_spec.js
+++ b/cms/static/js/spec/views/pages/container_subviews_spec.js
@@ -1,4 +1,4 @@
-define(["jquery", "underscore", "underscore.string", "common/js/spec_helpers/ajax_helpers",
+define(["jquery", "underscore", "underscore.string", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers",
"common/js/spec_helpers/template_helpers", "js/spec_helpers/edit_helpers",
"common/js/components/views/feedback_prompt", "js/views/pages/container",
"js/views/pages/container_subviews", "js/models/xblock_info", "js/views/utils/xblock_utils",
diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js
index 5de589c5cf..efbe55607a 100644
--- a/cms/static/js/spec/views/pages/course_outline_spec.js
+++ b/cms/static/js/spec/views/pages/course_outline_spec.js
@@ -1,6 +1,6 @@
-define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/components/utils/view_utils", "js/views/pages/course_outline",
- "js/models/xblock_outline_info", "js/utils/date_utils", "js/spec_helpers/edit_helpers",
- "common/js/spec_helpers/template_helpers", 'js/models/course',],
+define(["jquery", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "common/js/components/utils/view_utils",
+ "js/views/pages/course_outline", "js/models/xblock_outline_info", "js/utils/date_utils",
+ "js/spec_helpers/edit_helpers", "common/js/spec_helpers/template_helpers", 'js/models/course'],
function($, AjaxHelpers, ViewUtils, CourseOutlinePage, XBlockOutlineInfo, DateUtils,
EditHelpers, TemplateHelpers, Course) {
diff --git a/cms/static/js/spec/views/pages/course_rerun_spec.js b/cms/static/js/spec/views/pages/course_rerun_spec.js
index f9d62ed2ba..414131b69a 100644
--- a/cms/static/js/spec/views/pages/course_rerun_spec.js
+++ b/cms/static/js/spec/views/pages/course_rerun_spec.js
@@ -1,5 +1,6 @@
-define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/spec_helpers/view_helpers", "js/views/course_rerun",
- "js/views/utils/create_course_utils", "common/js/components/utils/view_utils", "jquery.simulate"],
+define(["jquery", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "common/js/spec_helpers/view_helpers",
+ "js/views/course_rerun", "js/views/utils/create_course_utils", "common/js/components/utils/view_utils",
+ "jquery.simulate"],
function ($, AjaxHelpers, ViewHelpers, CourseRerunUtils, CreateCourseUtilsFactory, ViewUtils) {
describe("Create course rerun page", function () {
var selectors = {
diff --git a/cms/static/js/spec/views/pages/index_spec.js b/cms/static/js/spec/views/pages/index_spec.js
index 243aac1024..85aad9575a 100644
--- a/cms/static/js/spec/views/pages/index_spec.js
+++ b/cms/static/js/spec/views/pages/index_spec.js
@@ -1,4 +1,6 @@
-define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/spec_helpers/view_helpers", "js/index",
+define(["jquery",
+ "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers",
+ "common/js/spec_helpers/view_helpers", "js/index",
"common/js/components/utils/view_utils"],
function ($, AjaxHelpers, ViewHelpers, IndexUtils, ViewUtils) {
describe("Course listing page", function () {
diff --git a/cms/static/js/spec/views/pages/library_users_spec.js b/cms/static/js/spec/views/pages/library_users_spec.js
index 3c6a4cb384..8a02ea5d69 100644
--- a/cms/static/js/spec/views/pages/library_users_spec.js
+++ b/cms/static/js/spec/views/pages/library_users_spec.js
@@ -1,5 +1,5 @@
define([
- "jquery", "common/js/spec_helpers/ajax_helpers", "common/js/spec_helpers/view_helpers",
+ "jquery", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "common/js/spec_helpers/view_helpers",
"js/factories/manage_users_lib", "common/js/components/utils/view_utils"
],
function ($, AjaxHelpers, ViewHelpers, ManageUsersFactory, ViewUtils) {
diff --git a/cms/static/js/spec/views/paging_spec.js b/cms/static/js/spec/views/paging_spec.js
index 8e5c42d37d..ad91f40587 100644
--- a/cms/static/js/spec/views/paging_spec.js
+++ b/cms/static/js/spec/views/paging_spec.js
@@ -1,11 +1,13 @@
define([
- "jquery",
- "URI",
- "common/js/spec_helpers/ajax_helpers",
- "edx-ui-toolkit/js/pagination/paging-collection",
- "js/views/paging",
- "js/views/paging_header"
-], function ($, URI, AjaxHelpers, PagingCollection, PagingView, PagingHeader) {
+ "jquery",
+ "URI",
+ "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers",
+ "edx-ui-toolkit/js/pagination/paging-collection",
+ "js/views/paging",
+ "js/views/paging_header"
+ ],
+ function ($, URI, AjaxHelpers, PagingCollection, PagingView, PagingHeader) {
+ 'use strict';
var createPageableItem = function(index) {
var id = 'item_' + index;
diff --git a/cms/static/js/spec/views/settings/main_spec.js b/cms/static/js/spec/views/settings/main_spec.js
index b9c1818e5d..790f195023 100644
--- a/cms/static/js/spec/views/settings/main_spec.js
+++ b/cms/static/js/spec/views/settings/main_spec.js
@@ -1,6 +1,6 @@
define([
'jquery', 'js/models/settings/course_details', 'js/views/settings/main',
- 'common/js/spec_helpers/ajax_helpers', 'common/js/spec_helpers/template_helpers',
+ 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'common/js/spec_helpers/template_helpers',
], function($, CourseDetailsModel, MainView, AjaxHelpers, TemplateHelpers) {
'use strict';
@@ -125,10 +125,9 @@ define([
//input some invalid values.
expect(entrance_exam_min_score.val('101').trigger('input')).toHaveClass("error");
expect(entrance_exam_min_score.val('invalidVal').trigger('input')).toHaveClass("error");
-
});
- it('should provide a default value for the minimum score percentage', function(){
+ it('should provide a default value for the minimum score percentage', function() {
var entrance_exam_min_score = this.view.$(SELECTORS.entrance_exam_min_score);
@@ -138,7 +137,7 @@ define([
.toEqual(this.model.defaults.entrance_exam_minimum_score_pct);
});
- it('show and hide the grade requirement section when the check box is selected and deselected respectively', function(){
+ it('shows and hide the grade requirement section appropriately', function() {
var entrance_exam_enabled_field = this.view.$(SELECTORS.entrance_exam_enabled_field);
diff --git a/cms/static/js/spec/views/unit_outline_spec.js b/cms/static/js/spec/views/unit_outline_spec.js
index 2f5b5ef338..e73db8d91e 100644
--- a/cms/static/js/spec/views/unit_outline_spec.js
+++ b/cms/static/js/spec/views/unit_outline_spec.js
@@ -1,8 +1,9 @@
-define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/spec_helpers/template_helpers",
+define(["jquery", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "common/js/spec_helpers/template_helpers",
"common/js/spec_helpers/view_helpers", "common/js/components/utils/view_utils", "js/models/course",
"js/views/unit_outline", "js/models/xblock_info"],
- function ($, AjaxHelpers, TemplateHelpers, ViewHelpers, ViewUtils,
+ function($, AjaxHelpers, TemplateHelpers, ViewHelpers, ViewUtils,
Course, UnitOutlineView, XBlockInfo) {
+ 'use strict';
describe("UnitOutlineView", function() {
var createUnitOutlineView, createMockXBlockInfo,
diff --git a/cms/static/js/spec/views/xblock_editor_spec.js b/cms/static/js/spec/views/xblock_editor_spec.js
index 701c71c499..b1fb40452d 100644
--- a/cms/static/js/spec/views/xblock_editor_spec.js
+++ b/cms/static/js/spec/views/xblock_editor_spec.js
@@ -1,4 +1,4 @@
-define([ "jquery", "underscore", "common/js/spec_helpers/ajax_helpers", "js/spec_helpers/edit_helpers",
+define([ "jquery", "underscore", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "js/spec_helpers/edit_helpers",
"js/views/xblock_editor", "js/models/xblock_info"],
function ($, _, AjaxHelpers, EditHelpers, XBlockEditorView, XBlockInfo) {
diff --git a/cms/static/js/spec/views/xblock_spec.js b/cms/static/js/spec/views/xblock_spec.js
index d192517626..23fa1eeadb 100644
--- a/cms/static/js/spec/views/xblock_spec.js
+++ b/cms/static/js/spec/views/xblock_spec.js
@@ -1,4 +1,4 @@
-define(["jquery", "URI", "common/js/spec_helpers/ajax_helpers", "common/js/components/utils/view_utils",
+define(["jquery", "URI", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "common/js/components/utils/view_utils",
"js/views/xblock", "js/models/xblock_info", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function ($, URI, AjaxHelpers, ViewUtils, XBlockView, XBlockInfo) {
"use strict";
diff --git a/cms/static/js/spec/views/xblock_string_field_editor_spec.js b/cms/static/js/spec/views/xblock_string_field_editor_spec.js
index b3fa04a7db..05ded85952 100644
--- a/cms/static/js/spec/views/xblock_string_field_editor_spec.js
+++ b/cms/static/js/spec/views/xblock_string_field_editor_spec.js
@@ -1,4 +1,4 @@
-define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/spec_helpers/template_helpers",
+define(["jquery", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "common/js/spec_helpers/template_helpers",
"js/spec_helpers/edit_helpers", "js/models/xblock_info", "js/views/xblock_string_field_editor"],
function ($, AjaxHelpers, TemplateHelpers, EditHelpers, XBlockInfo, XBlockStringFieldEditor) {
describe("XBlockStringFieldEditorView", function () {
diff --git a/cms/static/js/spec_helpers/edit_helpers.js b/cms/static/js/spec_helpers/edit_helpers.js
index fa100d7907..4d46e6af42 100644
--- a/cms/static/js/spec_helpers/edit_helpers.js
+++ b/cms/static/js/spec_helpers/edit_helpers.js
@@ -1,9 +1,9 @@
/**
* Provides helper methods for invoking Studio editors in Jasmine tests.
*/
-define(["jquery", "underscore", "common/js/spec_helpers/ajax_helpers", "common/js/spec_helpers/template_helpers",
- "js/spec_helpers/modal_helpers", "js/views/modals/edit_xblock", "js/collections/component_template",
- "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
+define(["jquery", "underscore", "edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers",
+ "common/js/spec_helpers/template_helpers", "js/spec_helpers/modal_helpers", "js/views/modals/edit_xblock",
+ "js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function($, _, AjaxHelpers, TemplateHelpers, modal_helpers, EditXBlockModal, ComponentTemplates) {
var installMockXBlock, uninstallMockXBlock, installMockXModule, uninstallMockXModule,
diff --git a/cms/static/js/views/module_edit.js b/cms/static/js/views/module_edit.js
new file mode 100644
index 0000000000..64aa7bafff
--- /dev/null
+++ b/cms/static/js/views/module_edit.js
@@ -0,0 +1,111 @@
+(function() {
+ 'use strict';
+
+ var __hasProp = {}.hasOwnProperty,
+ __extends = function(child, parent) {
+ var key;
+ for (key in parent) {
+ if (__hasProp.call(parent, key)) {
+ child[key] = parent[key];
+ }
+ }
+ function Ctor() {
+ this.constructor = child;
+ }
+ Ctor.prototype = parent.prototype;
+ child.prototype = new Ctor();
+ child.__super__ = parent.prototype;
+ return child;
+ };
+
+ define(['jquery', 'underscore', 'gettext', 'xblock/runtime.v1', 'js/views/xblock', 'js/views/modals/edit_xblock'],
+ function($, _, gettext, XBlock, XBlockView, EditXBlockModal) {
+ var ModuleEdit = (function(_super) {
+
+ __extends(ModuleEdit, _super);
+
+ function ModuleEdit() {
+ return ModuleEdit.__super__.constructor.apply(this, arguments);
+ }
+
+ ModuleEdit.prototype.tagName = 'li';
+
+ ModuleEdit.prototype.className = 'component';
+
+ ModuleEdit.prototype.editorMode = 'editor-mode';
+
+ ModuleEdit.prototype.events = {
+ 'click .edit-button': 'clickEditButton',
+ 'click .delete-button': 'onDelete'
+ };
+
+ ModuleEdit.prototype.initialize = function() {
+ this.onDelete = this.options.onDelete;
+ return this.render();
+ };
+
+ ModuleEdit.prototype.loadDisplay = function() {
+ var xblockElement;
+ xblockElement = this.$el.find('.xblock-student_view');
+ if (xblockElement.length > 0) {
+ return XBlock.initializeBlock(xblockElement);
+ }
+ };
+
+ ModuleEdit.prototype.createItem = function(parent, payload, callback) {
+ var _this = this;
+ if (_.isNull(callback)) {
+ callback = function() {};
+ }
+ payload.parent_locator = parent;
+ return $.postJSON(this.model.urlRoot + '/', payload, function(data) {
+ _this.model.set({
+ id: data.locator
+ });
+ _this.$el.data('locator', data.locator);
+ _this.$el.data('courseKey', data.courseKey);
+ return _this.render();
+ }).success(callback);
+ };
+
+ ModuleEdit.prototype.loadView = function(viewName, target, callback) {
+ var _this = this;
+ if (this.model.id) {
+ return $.ajax({
+ url: '' + (decodeURIComponent(this.model.url())) + '/' + viewName,
+ type: 'GET',
+ cache: false,
+ headers: {
+ Accept: 'application/json'
+ },
+ success: function(fragment) {
+ return _this.renderXBlockFragment(fragment, target).done(callback);
+ }
+ });
+ }
+ };
+
+ ModuleEdit.prototype.render = function() {
+ var _this = this;
+ return this.loadView('student_view', this.$el, function() {
+ _this.loadDisplay();
+ return _this.delegateEvents();
+ });
+ };
+
+ ModuleEdit.prototype.clickEditButton = function(event) {
+ var modal;
+ event.preventDefault();
+ modal = new EditXBlockModal();
+ return modal.edit(this.$el, this.model, {
+ refresh: _.bind(this.render, this)
+ });
+ };
+
+ return ModuleEdit;
+
+ })(XBlockView);
+ return ModuleEdit;
+ });
+
+}).call(this);
diff --git a/cms/static/js/views/tabs.js b/cms/static/js/views/tabs.js
new file mode 100644
index 0000000000..04ace25d7e
--- /dev/null
+++ b/cms/static/js/views/tabs.js
@@ -0,0 +1,203 @@
+(function(analytics, course_location_analytics) {
+ 'use strict';
+
+ var __hasProp = {}.hasOwnProperty,
+ __extends = function(child, parent) {
+ var key;
+ for (key in parent) {
+ if (__hasProp.call(parent, key)) {
+ child[key] = parent[key];
+ }
+ }
+ function Ctor() {
+ this.constructor = child;
+ }
+ Ctor.prototype = parent.prototype;
+ child.prototype = new Ctor();
+ child.__super__ = parent.prototype;
+ return child;
+ };
+
+ define(['underscore', 'jquery', 'jquery.ui', 'backbone', 'common/js/components/views/feedback_prompt',
+ 'common/js/components/views/feedback_notification', 'js/views/module_edit',
+ 'js/models/module_info', 'js/utils/module'],
+ function(_, $, ui, Backbone, PromptView, NotificationView, ModuleEditView, ModuleModel, ModuleUtils) {
+ var TabsEdit;
+ TabsEdit = (function(_super) {
+
+ __extends(TabsEdit, _super);
+
+ function TabsEdit() {
+ var self = this;
+ this.deleteTab = function() {
+ return TabsEdit.prototype.deleteTab.apply(self, arguments);
+ };
+ this.addNewTab = function() {
+ return TabsEdit.prototype.addNewTab.apply(self, arguments);
+ };
+ this.tabMoved = function() {
+ return TabsEdit.prototype.tabMoved.apply(self, arguments);
+ };
+ this.toggleVisibilityOfTab = function() {
+ return TabsEdit.prototype.toggleVisibilityOfTab.apply(self, arguments);
+ };
+ this.initialize = function() {
+ return TabsEdit.prototype.initialize.apply(self, arguments);
+ };
+ return TabsEdit.__super__.constructor.apply(this, arguments);
+ }
+
+ TabsEdit.prototype.initialize = function(options) {
+ var self = this;
+ this.$('.component').each(function(idx, element) {
+ var model;
+ model = new ModuleModel({
+ id: $(element).data('locator')
+ });
+ return new ModuleEditView({
+ el: element,
+ onDelete: self.deleteTab,
+ model: model
+ });
+ });
+ this.options = _.extend({}, options);
+ this.options.mast.find('.new-tab').on('click', this.addNewTab);
+ $('.add-pages .new-tab').on('click', this.addNewTab);
+ $('.toggle-checkbox').on('click', this.toggleVisibilityOfTab);
+ return this.$('.course-nav-list').sortable({
+ handle: '.drag-handle',
+ update: this.tabMoved,
+ helper: 'clone',
+ opacity: '0.5',
+ placeholder: 'component-placeholder',
+ forcePlaceholderSize: true,
+ axis: 'y',
+ items: '> .is-movable'
+ });
+ };
+
+ TabsEdit.prototype.toggleVisibilityOfTab = function(event) {
+ var checkbox_element, saving, tab_element;
+ checkbox_element = event.target;
+ tab_element = $(checkbox_element).parents('.course-tab')[0];
+ saving = new NotificationView.Mini({
+ title: gettext('Saving')
+ });
+ saving.show();
+ return $.ajax({
+ type: 'POST',
+ url: this.model.url(),
+ data: JSON.stringify({
+ tab_id_locator: {
+ tab_id: $(tab_element).data('tab-id'),
+ tab_locator: $(tab_element).data('locator')
+ },
+ is_hidden: $(checkbox_element).is(':checked')
+ }),
+ contentType: 'application/json'
+ }).success(function() {
+ return saving.hide();
+ });
+ };
+
+ TabsEdit.prototype.tabMoved = function() {
+ var saving, tabs;
+ tabs = [];
+ this.$('.course-tab').each(function(idx, element) {
+ return tabs.push({
+ tab_id: $(element).data('tab-id'),
+ tab_locator: $(element).data('locator')
+ });
+ });
+ analytics.track('Reordered Pages', {
+ course: course_location_analytics
+ });
+ saving = new NotificationView.Mini({
+ title: gettext('Saving')
+ });
+ saving.show();
+ return $.ajax({
+ type: 'POST',
+ url: this.model.url(),
+ data: JSON.stringify({
+ tabs: tabs
+ }),
+ contentType: 'application/json'
+ }).success(function() {
+ return saving.hide();
+ });
+ };
+
+ TabsEdit.prototype.addNewTab = function(event) {
+ var editor;
+ event.preventDefault();
+ editor = new ModuleEditView({
+ onDelete: this.deleteTab,
+ model: new ModuleModel()
+ });
+ $('.new-component-item').before(editor.$el);
+ editor.$el.addClass('course-tab is-movable');
+ editor.$el.addClass('new');
+ setTimeout(function() {
+ return editor.$el.removeClass('new');
+ }, 1000);
+ $('html, body').animate({
+ scrollTop: $('.new-component-item').offset().top
+ }, 500);
+ editor.createItem(this.model.get('id'), {
+ category: 'static_tab'
+ });
+ return analytics.track('Added Page', {
+ course: course_location_analytics
+ });
+ };
+
+ TabsEdit.prototype.deleteTab = function(event) {
+ var confirm;
+ confirm = new PromptView.Warning({
+ title: gettext('Delete Page Confirmation'),
+ message: gettext('Are you sure you want to delete this page? This action cannot be undone.'),
+ actions: {
+ primary: {
+ text: gettext('OK'),
+ click: function(view) {
+ var $component, deleting;
+ view.hide();
+ $component = $(event.currentTarget).parents('.component');
+ analytics.track('Deleted Page', {
+ course: course_location_analytics,
+ id: $component.data('locator')
+ });
+ deleting = new NotificationView.Mini({
+ title: gettext('Deleting')
+ });
+ deleting.show();
+ return $.ajax({
+ type: 'DELETE',
+ url: ModuleUtils.getUpdateUrl($component.data('locator'))
+ }).success(function() {
+ $component.remove();
+ return deleting.hide();
+ });
+ }
+ },
+ secondary: [
+ {
+ text: gettext('Cancel'),
+ click: function(view) {
+ return view.hide();
+ }
+ }
+ ]
+ }
+ });
+ return confirm.show();
+ };
+
+ return TabsEdit;
+
+ })(Backbone.View);
+ return TabsEdit;
+ });
+
+}).call(this, analytics, course_location_analytics); //jshint ignore:line
diff --git a/cms/static/karma_cms.conf.js b/cms/static/karma_cms.conf.js
index edfb65d9f4..f2ef478cd3 100644
--- a/cms/static/karma_cms.conf.js
+++ b/cms/static/karma_cms.conf.js
@@ -21,11 +21,13 @@ var options = {
// Make sure the patterns in sourceFiles and specFiles do not match the same file.
// Otherwise Istanbul which is used for coverage tracking will cause tests to not run.
sourceFiles: [
+ {pattern: 'cms/**/!(*spec|djangojs).js'},
{pattern: 'coffee/src/**/!(*spec).js'},
{pattern: 'js/**/!(*spec|djangojs).js'}
],
specFiles: [
+ {pattern: 'cms/**/*spec.js'},
{pattern: 'coffee/spec/**/*spec.js'},
{pattern: 'js/certificates/spec/**/*spec.js'},
{pattern: 'js/spec/**/*spec.js'}
@@ -37,10 +39,10 @@ var options = {
],
runFiles: [
- {pattern: 'coffee/spec/main.js', included: true}
+ {pattern: 'cms/js/spec/main.js', included: true}
]
};
-module.exports = function (config) {
+module.exports = function(config) {
configModule.configure(config, options);
};
diff --git a/cms/static/karma_cms_squire.conf.js b/cms/static/karma_cms_squire.conf.js
index 093e51bb55..d8bdda0c98 100644
--- a/cms/static/karma_cms_squire.conf.js
+++ b/cms/static/karma_cms_squire.conf.js
@@ -36,7 +36,7 @@ var options = {
],
runFiles: [
- {pattern: 'coffee/spec/main_squire.js', included: true}
+ {pattern: 'cms/js/spec/main_squire.js', included: true}
]
};
diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss
index 8da5903138..3fd284bdea 100644
--- a/cms/static/sass/_base.scss
+++ b/cms/static/sass/_base.scss
@@ -36,8 +36,8 @@ body, input, button {
font-family: 'Open Sans', sans-serif;
}
-// we want to hide the outline on the focusable element
-main {
+// removing the outline on any element that we make programmatically focusable
+[tabindex="-1"] {
outline: none;
}
diff --git a/cms/templates/js/content-group-details.underscore b/cms/templates/js/content-group-details.underscore
index 041439f7e4..5827c0f9c8 100644
--- a/cms/templates/js/content-group-details.underscore
+++ b/cms/templates/js/content-group-details.underscore
@@ -32,7 +32,7 @@
<% } else { %>
-
+
<% } %>
diff --git a/cms/templates/js/group-configuration-details.underscore b/cms/templates/js/group-configuration-details.underscore
index d6e989bab8..8310a2a657 100644
--- a/cms/templates/js/group-configuration-details.underscore
+++ b/cms/templates/js/group-configuration-details.underscore
@@ -50,7 +50,7 @@
<% } else { %>
-
+
<% } %>
diff --git a/common/test/data/toy/static/sample_static.txt b/common/djangoapps/config_models/management/__init__.py
similarity index 100%
rename from common/test/data/toy/static/sample_static.txt
rename to common/djangoapps/config_models/management/__init__.py
diff --git a/common/djangoapps/config_models/management/commands/__init__.py b/common/djangoapps/config_models/management/commands/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/config_models/management/commands/populate_model.py b/common/djangoapps/config_models/management/commands/populate_model.py
new file mode 100644
index 0000000000..d41ceae58b
--- /dev/null
+++ b/common/djangoapps/config_models/management/commands/populate_model.py
@@ -0,0 +1,72 @@
+"""
+Populates a ConfigurationModel by deserializing JSON data contained in a file.
+"""
+import os
+from optparse import make_option
+
+from django.core.management.base import BaseCommand, CommandError
+from django.utils.translation import ugettext_lazy as _
+
+from config_models.utils import deserialize_json
+
+
+class Command(BaseCommand):
+ """
+ This command will deserialize the JSON data in the supplied file to populate
+ a ConfigurationModel. Note that this will add new entries to the model, but it
+ will not delete any entries (ConfigurationModel entries are read-only).
+ """
+ help = """
+ Populates a ConfigurationModel by deserializing the supplied JSON.
+
+ JSON should be in a file, with the following format:
+
+ { "model": "config_models.ExampleConfigurationModel",
+ "data":
+ [
+ { "enabled": True,
+ "color": "black"
+ ...
+ },
+ { "enabled": False,
+ "color": "yellow"
+ ...
+ },
+ ...
+ ]
+ }
+
+ A username corresponding to an existing user must be specified to indicate who
+ is executing the command.
+
+ $ ... populate_model -f path/to/file.json -u username
+ """
+
+ option_list = BaseCommand.option_list + (
+ make_option('-f', '--file',
+ metavar='JSON_FILE',
+ dest='file',
+ default=False,
+ help='JSON file to import ConfigurationModel data'),
+ make_option('-u', '--username',
+ metavar='USERNAME',
+ dest='username',
+ default=False,
+ help='username to specify who is executing the command'),
+ )
+
+ def handle(self, *args, **options):
+ if 'file' not in options or not options['file']:
+ raise CommandError(_("A file containing JSON must be specified."))
+
+ if 'username' not in options or not options['username']:
+ raise CommandError(_("A valid username must be specified."))
+
+ json_file = options['file']
+ if not os.path.exists(json_file):
+ raise CommandError(_("File {0} does not exist").format(json_file))
+
+ self.stdout.write(_("Importing JSON data from file {0}").format(json_file))
+ with open(json_file) as data:
+ created_entries = deserialize_json(data, options['username'])
+ self.stdout.write(_("Import complete, {0} new entries created").format(created_entries))
diff --git a/common/djangoapps/config_models/models.py b/common/djangoapps/config_models/models.py
index 5528f084cf..ab429c701d 100644
--- a/common/djangoapps/config_models/models.py
+++ b/common/djangoapps/config_models/models.py
@@ -6,6 +6,9 @@ from django.contrib.auth.models import User
from django.core.cache import caches, InvalidCacheBackendError
from django.utils.translation import ugettext_lazy as _
+from rest_framework.utils import model_meta
+
+
try:
cache = caches['configuration'] # pylint: disable=invalid-name
except InvalidCacheBackendError:
@@ -176,3 +179,58 @@ class ConfigurationModel(models.Model):
values = list(cls.objects.values_list(*key_fields, flat=flat).order_by().distinct())
cache.set(cache_key, values, cls.cache_timeout)
return values
+
+ def fields_equal(self, instance, fields_to_ignore=("id", "change_date", "changed_by")):
+ """
+ Compares this instance's fields to the supplied instance to test for equality.
+ This will ignore any fields in `fields_to_ignore`.
+
+ Note that this method ignores many-to-many fields.
+
+ Args:
+ instance: the model instance to compare
+ fields_to_ignore: List of fields that should not be compared for equality. By default
+ includes `id`, `change_date`, and `changed_by`.
+
+ Returns: True if the checked fields are all equivalent, else False
+ """
+ for field in self._meta.get_fields():
+ if not field.many_to_many and field.name not in fields_to_ignore:
+ if getattr(instance, field.name) != getattr(self, field.name):
+ return False
+
+ return True
+
+ @classmethod
+ def equal_to_current(cls, json, fields_to_ignore=("id", "change_date", "changed_by")):
+ """
+ Compares for equality this instance to a model instance constructed from the supplied JSON.
+ This will ignore any fields in `fields_to_ignore`.
+
+ Note that this method cannot handle fields with many-to-many associations, as those can only
+ be set on a saved model instance (and saving the model instance will create a new entry).
+ All many-to-many field entries will be removed before the equality comparison is done.
+
+ Args:
+ json: json representing an entry to compare
+ fields_to_ignore: List of fields that should not be compared for equality. By default
+ includes `id`, `change_date`, and `changed_by`.
+
+ Returns: True if the checked fields are all equivalent, else False
+ """
+
+ # Remove many-to-many relationships from json.
+ # They require an instance to be already saved.
+ info = model_meta.get_field_info(cls)
+ for field_name, relation_info in info.relations.items():
+ if relation_info.to_many and (field_name in json):
+ json.pop(field_name)
+
+ new_instance = cls(**json)
+ key_field_args = tuple(getattr(new_instance, key) for key in cls.KEY_FIELDS)
+ current = cls.current(*key_field_args)
+ # If current.id is None, no entry actually existed and the "current" method created it.
+ if current.id is not None:
+ return current.fields_equal(new_instance, fields_to_ignore)
+
+ return False
diff --git a/common/djangoapps/config_models/tests/__init__.py b/common/djangoapps/config_models/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/common/djangoapps/config_models/tests/data/data.json b/common/djangoapps/config_models/tests/data/data.json
new file mode 100644
index 0000000000..e6977c7d54
--- /dev/null
+++ b/common/djangoapps/config_models/tests/data/data.json
@@ -0,0 +1,14 @@
+{
+ "model": "config_models.ExampleDeserializeConfig",
+ "data": [
+ {
+ "name": "betty",
+ "enabled": true,
+ "int_field": 5
+ },
+ {
+ "name": "fred",
+ "enabled": false
+ }
+ ]
+}
diff --git a/common/djangoapps/config_models/tests/test_model_deserialization.py b/common/djangoapps/config_models/tests/test_model_deserialization.py
new file mode 100644
index 0000000000..af1ff81e49
--- /dev/null
+++ b/common/djangoapps/config_models/tests/test_model_deserialization.py
@@ -0,0 +1,219 @@
+"""
+Tests of the populate_model management command and its helper utils.deserialize_json method.
+"""
+
+import textwrap
+import os.path
+
+from django.utils import timezone
+from django.utils.six import BytesIO
+
+from django.contrib.auth.models import User
+from django.core.management.base import CommandError
+from django.db import models
+
+from config_models.management.commands import populate_model
+from config_models.models import ConfigurationModel
+from config_models.utils import deserialize_json
+from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
+
+
+class ExampleDeserializeConfig(ConfigurationModel):
+ """
+ Test model for testing deserialization of ``ConfigurationModels`` with keyed configuration.
+ """
+ KEY_FIELDS = ('name',)
+
+ name = models.TextField()
+ int_field = models.IntegerField(default=10)
+
+ def __unicode__(self):
+ return "ExampleDeserializeConfig(enabled={}, name={}, int_field={})".format(
+ self.enabled, self.name, self.int_field
+ )
+
+
+class DeserializeJSONTests(CacheIsolationTestCase):
+ """
+ Tests of deserializing the JSON representation of ConfigurationModels.
+ """
+ def setUp(self):
+ super(DeserializeJSONTests, self).setUp()
+ self.test_username = 'test_worker'
+ User.objects.create_user(username=self.test_username)
+ self.fixture_path = os.path.join(os.path.dirname(__file__), 'data', 'data.json')
+
+ def test_deserialize_models(self):
+ """
+ Tests the "happy path", where 2 instances of the test model should be created.
+ A valid username is supplied for the operation.
+ """
+ start_date = timezone.now()
+ with open(self.fixture_path) as data:
+ entries_created = deserialize_json(data, self.test_username)
+ self.assertEquals(2, entries_created)
+
+ self.assertEquals(2, ExampleDeserializeConfig.objects.count())
+
+ betty = ExampleDeserializeConfig.current('betty')
+ self.assertTrue(betty.enabled)
+ self.assertEquals(5, betty.int_field)
+ self.assertGreater(betty.change_date, start_date)
+ self.assertEquals(self.test_username, betty.changed_by.username)
+
+ fred = ExampleDeserializeConfig.current('fred')
+ self.assertFalse(fred.enabled)
+ self.assertEquals(10, fred.int_field)
+ self.assertGreater(fred.change_date, start_date)
+ self.assertEquals(self.test_username, fred.changed_by.username)
+
+ def test_existing_entries_not_removed(self):
+ """
+ Any existing configuration model entries are retained
+ (though they may be come history)-- deserialize_json is purely additive.
+ """
+ ExampleDeserializeConfig(name="fred", enabled=True).save()
+ ExampleDeserializeConfig(name="barney", int_field=200).save()
+
+ with open(self.fixture_path) as data:
+ entries_created = deserialize_json(data, self.test_username)
+ self.assertEquals(2, entries_created)
+
+ self.assertEquals(4, ExampleDeserializeConfig.objects.count())
+ self.assertEquals(3, len(ExampleDeserializeConfig.objects.current_set()))
+
+ self.assertEquals(5, ExampleDeserializeConfig.current('betty').int_field)
+ self.assertEquals(200, ExampleDeserializeConfig.current('barney').int_field)
+
+ # The JSON file changes "enabled" to False for Fred.
+ fred = ExampleDeserializeConfig.current('fred')
+ self.assertFalse(fred.enabled)
+
+ def test_duplicate_entries_not_made(self):
+ """
+ If there is no change in an entry (besides changed_by and change_date),
+ a new entry is not made.
+ """
+ with open(self.fixture_path) as data:
+ entries_created = deserialize_json(data, self.test_username)
+ self.assertEquals(2, entries_created)
+
+ with open(self.fixture_path) as data:
+ entries_created = deserialize_json(data, self.test_username)
+ self.assertEquals(0, entries_created)
+
+ # Importing twice will still only result in 2 records (second import a no-op).
+ self.assertEquals(2, ExampleDeserializeConfig.objects.count())
+
+ # Change Betty.
+ betty = ExampleDeserializeConfig.current('betty')
+ betty.int_field = -8
+ betty.save()
+
+ self.assertEquals(3, ExampleDeserializeConfig.objects.count())
+ self.assertEquals(-8, ExampleDeserializeConfig.current('betty').int_field)
+
+ # Now importing will add a new entry for Betty.
+ with open(self.fixture_path) as data:
+ entries_created = deserialize_json(data, self.test_username)
+ self.assertEquals(1, entries_created)
+
+ self.assertEquals(4, ExampleDeserializeConfig.objects.count())
+ self.assertEquals(5, ExampleDeserializeConfig.current('betty').int_field)
+
+ def test_bad_username(self):
+ """
+ Tests the error handling when the specified user does not exist.
+ """
+ test_json = textwrap.dedent("""
+ {
+ "model": "config_models.ExampleDeserializeConfig",
+ "data": [{"name": "dino"}]
+ }
+ """)
+ with self.assertRaisesRegexp(Exception, "User matching query does not exist"):
+ deserialize_json(BytesIO(test_json), "unknown_username")
+
+ def test_invalid_json(self):
+ """
+ Tests the error handling when there is invalid JSON.
+ """
+ test_json = textwrap.dedent("""
+ {
+ "model": "config_models.ExampleDeserializeConfig",
+ "data": [{"name": "dino"
+ """)
+ with self.assertRaisesRegexp(Exception, "JSON parse error"):
+ deserialize_json(BytesIO(test_json), self.test_username)
+
+ def test_invalid_model(self):
+ """
+ Tests the error handling when the configuration model specified does not exist.
+ """
+ test_json = textwrap.dedent("""
+ {
+ "model": "xxx.yyy",
+ "data":[{"name": "dino"}]
+ }
+ """)
+ with self.assertRaisesRegexp(Exception, "No installed app"):
+ deserialize_json(BytesIO(test_json), self.test_username)
+
+
+class PopulateModelTestCase(CacheIsolationTestCase):
+ """
+ Tests of populate model management command.
+ """
+ def setUp(self):
+ super(PopulateModelTestCase, self).setUp()
+ self.file_path = os.path.join(os.path.dirname(__file__), 'data', 'data.json')
+ self.test_username = 'test_management_worker'
+ User.objects.create_user(username=self.test_username)
+
+ def test_run_command(self):
+ """
+ Tests the "happy path", where 2 instances of the test model should be created.
+ A valid username is supplied for the operation.
+ """
+ _run_command(file=self.file_path, username=self.test_username)
+ self.assertEquals(2, ExampleDeserializeConfig.objects.count())
+
+ betty = ExampleDeserializeConfig.current('betty')
+ self.assertEquals(self.test_username, betty.changed_by.username)
+
+ fred = ExampleDeserializeConfig.current('fred')
+ self.assertEquals(self.test_username, fred.changed_by.username)
+
+ def test_no_user_specified(self):
+ """
+ Tests that a username must be specified.
+ """
+ with self.assertRaisesRegexp(CommandError, "A valid username must be specified"):
+ _run_command(file=self.file_path)
+
+ def test_bad_user_specified(self):
+ """
+ Tests that a username must be specified.
+ """
+ with self.assertRaisesRegexp(Exception, "User matching query does not exist"):
+ _run_command(file=self.file_path, username="does_not_exist")
+
+ def test_no_file_specified(self):
+ """
+ Tests the error handling when no JSON file is supplied.
+ """
+ with self.assertRaisesRegexp(CommandError, "A file containing JSON must be specified"):
+ _run_command(username=self.test_username)
+
+ def test_bad_file_specified(self):
+ """
+ Tests the error handling when the path to the JSON file is incorrect.
+ """
+ with self.assertRaisesRegexp(CommandError, "File does/not/exist.json does not exist"):
+ _run_command(file="does/not/exist.json", username=self.test_username)
+
+
+def _run_command(*args, **kwargs):
+ """Run the management command to deserializer JSON ConfigurationModel data. """
+ command = populate_model.Command()
+ return command.handle(*args, **kwargs)
diff --git a/common/djangoapps/config_models/tests.py b/common/djangoapps/config_models/tests/tests.py
similarity index 73%
rename from common/djangoapps/config_models/tests.py
rename to common/djangoapps/config_models/tests/tests.py
index 15f954e336..538058c109 100644
--- a/common/djangoapps/config_models/tests.py
+++ b/common/djangoapps/config_models/tests/tests.py
@@ -25,6 +25,24 @@ class ExampleConfig(ConfigurationModel):
string_field = models.TextField()
int_field = models.IntegerField(default=10)
+ def __unicode__(self):
+ return "ExampleConfig(enabled={}, string_field={}, int_field={})".format(
+ self.enabled, self.string_field, self.int_field
+ )
+
+
+class ManyToManyExampleConfig(ConfigurationModel):
+ """
+ Test model configuration with a many-to-many field.
+ """
+ cache_timeout = 300
+
+ string_field = models.TextField()
+ many_user_field = models.ManyToManyField(User, related_name='topic_many_user_field')
+
+ def __unicode__(self):
+ return "ManyToManyExampleConfig(enabled={}, string_field={})".format(self.enabled, self.string_field)
+
@patch('config_models.models.cache')
class ConfigurationModelTests(TestCase):
@@ -40,7 +58,7 @@ class ConfigurationModelTests(TestCase):
ExampleConfig(changed_by=self.user).save()
mock_cache.delete.assert_called_with(ExampleConfig.cache_key_name())
- def test_cache_key_name(self, _mock_cache):
+ def test_cache_key_name(self, __):
self.assertEquals(ExampleConfig.cache_key_name(), 'configuration/ExampleConfig/current')
def test_no_config_empty_cache(self, mock_cache):
@@ -103,6 +121,64 @@ class ConfigurationModelTests(TestCase):
self.assertEquals(2, ExampleConfig.objects.all().count())
+ def test_equality(self, mock_cache):
+ mock_cache.get.return_value = None
+
+ config = ExampleConfig(changed_by=self.user, string_field='first')
+ config.save()
+
+ self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first"}))
+ self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first", "enabled": False}))
+ self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first", "int_field": 10}))
+
+ self.assertFalse(ExampleConfig.equal_to_current({"string_field": "first", "enabled": True}))
+ self.assertFalse(ExampleConfig.equal_to_current({"string_field": "first", "int_field": 20}))
+ self.assertFalse(ExampleConfig.equal_to_current({"string_field": "second"}))
+
+ self.assertFalse(ExampleConfig.equal_to_current({}))
+
+ def test_equality_custom_fields_to_ignore(self, mock_cache):
+ mock_cache.get.return_value = None
+
+ config = ExampleConfig(changed_by=self.user, string_field='first')
+ config.save()
+
+ # id, change_date, and changed_by will all be different for a newly created entry
+ self.assertTrue(ExampleConfig.equal_to_current({"string_field": "first"}))
+ self.assertFalse(
+ ExampleConfig.equal_to_current({"string_field": "first"}, fields_to_ignore=("change_date", "changed_by"))
+ )
+ self.assertFalse(
+ ExampleConfig.equal_to_current({"string_field": "first"}, fields_to_ignore=("id", "changed_by"))
+ )
+ self.assertFalse(
+ ExampleConfig.equal_to_current({"string_field": "first"}, fields_to_ignore=("change_date", "id"))
+ )
+
+ # Test the ability to ignore a different field ("int_field").
+ self.assertFalse(ExampleConfig.equal_to_current({"string_field": "first", "int_field": 20}))
+ self.assertTrue(
+ ExampleConfig.equal_to_current(
+ {"string_field": "first", "int_field": 20},
+ fields_to_ignore=("id", "change_date", "changed_by", "int_field")
+ )
+ )
+
+ def test_equality_ignores_many_to_many(self, mock_cache):
+ mock_cache.get.return_value = None
+ config = ManyToManyExampleConfig(changed_by=self.user, string_field='first')
+ config.save()
+
+ second_user = User(username="second_user")
+ second_user.save()
+ config.many_user_field.add(second_user) # pylint: disable=no-member
+ config.save()
+
+ # The many-to-many field is ignored in comparison.
+ self.assertTrue(
+ ManyToManyExampleConfig.equal_to_current({"string_field": "first", "many_user_field": "removed"})
+ )
+
class ExampleKeyedConfig(ConfigurationModel):
"""
@@ -120,6 +196,11 @@ class ExampleKeyedConfig(ConfigurationModel):
string_field = models.TextField()
int_field = models.IntegerField(default=10)
+ def __unicode__(self):
+ return "ExampleKeyedConfig(enabled={}, left={}, right={}, string_field={}, int_field={})".format(
+ self.enabled, self.left, self.right, self.string_field, self.int_field
+ )
+
@ddt.ddt
@patch('config_models.models.cache')
@@ -294,6 +375,45 @@ class KeyedConfigurationModelTests(TestCase):
mock_cache.get.return_value = fake_result
self.assertEquals(ExampleKeyedConfig.key_values(), fake_result)
+ def test_equality(self, mock_cache):
+ mock_cache.get.return_value = None
+
+ config1 = ExampleKeyedConfig(left='left_a', right='right_a', int_field=1, changed_by=self.user)
+ config1.save()
+
+ config2 = ExampleKeyedConfig(left='left_b', right='right_b', int_field=2, changed_by=self.user, enabled=True)
+ config2.save()
+
+ config3 = ExampleKeyedConfig(left='left_c', changed_by=self.user)
+ config3.save()
+
+ self.assertTrue(
+ ExampleKeyedConfig.equal_to_current({"left": "left_a", "right": "right_a", "int_field": 1})
+ )
+ self.assertTrue(
+ ExampleKeyedConfig.equal_to_current({"left": "left_b", "right": "right_b", "int_field": 2, "enabled": True})
+ )
+ self.assertTrue(
+ ExampleKeyedConfig.equal_to_current({"left": "left_c"})
+ )
+
+ self.assertFalse(
+ ExampleKeyedConfig.equal_to_current(
+ {"left": "left_a", "right": "right_a", "int_field": 1, "string_field": "foo"}
+ )
+ )
+ self.assertFalse(
+ ExampleKeyedConfig.equal_to_current({"left": "left_a", "int_field": 1})
+ )
+ self.assertFalse(
+ ExampleKeyedConfig.equal_to_current({"left": "left_b", "right": "right_b", "int_field": 2})
+ )
+ self.assertFalse(
+ ExampleKeyedConfig.equal_to_current({"left": "left_c", "int_field": 11})
+ )
+
+ self.assertFalse(ExampleKeyedConfig.equal_to_current({}))
+
@ddt.ddt
class ConfigurationModelAPITests(TestCase):
diff --git a/common/djangoapps/config_models/utils.py b/common/djangoapps/config_models/utils.py
new file mode 100644
index 0000000000..10a293af4d
--- /dev/null
+++ b/common/djangoapps/config_models/utils.py
@@ -0,0 +1,69 @@
+"""
+Utilities for working with ConfigurationModels.
+"""
+from django.apps import apps
+from rest_framework.parsers import JSONParser
+from rest_framework.serializers import ModelSerializer
+from django.contrib.auth.models import User
+
+
+def get_serializer_class(configuration_model):
+ """ Returns a ConfigurationModel serializer class for the supplied configuration_model. """
+ class AutoConfigModelSerializer(ModelSerializer):
+ """Serializer class for configuration models."""
+
+ class Meta(object):
+ """Meta information for AutoConfigModelSerializer."""
+ model = configuration_model
+
+ def create(self, validated_data):
+ if "changed_by_username" in self.context:
+ validated_data['changed_by'] = User.objects.get(username=self.context["changed_by_username"])
+ return super(AutoConfigModelSerializer, self).create(validated_data)
+
+ return AutoConfigModelSerializer
+
+
+def deserialize_json(stream, username):
+ """
+ Given a stream containing JSON, deserializers the JSON into ConfigurationModel instances.
+
+ The stream is expected to be in the following format:
+ { "model": "config_models.ExampleConfigurationModel",
+ "data":
+ [
+ { "enabled": True,
+ "color": "black"
+ ...
+ },
+ { "enabled": False,
+ "color": "yellow"
+ ...
+ },
+ ...
+ ]
+ }
+
+ If the provided stream does not contain valid JSON for the ConfigurationModel specified,
+ an Exception will be raised.
+
+ Arguments:
+ stream: The stream of JSON, as described above.
+ username: The username of the user making the change. This must match an existing user.
+
+ Returns: the number of created entries
+ """
+ parsed_json = JSONParser().parse(stream)
+ serializer_class = get_serializer_class(apps.get_model(parsed_json["model"]))
+ list_serializer = serializer_class(data=parsed_json["data"], context={"changed_by_username": username}, many=True)
+ if list_serializer.is_valid():
+ model_class = serializer_class.Meta.model
+ for data in reversed(list_serializer.validated_data):
+ if model_class.equal_to_current(data):
+ list_serializer.validated_data.remove(data)
+
+ entries_created = len(list_serializer.validated_data)
+ list_serializer.save()
+ return entries_created
+ else:
+ raise Exception(list_serializer.error_messages)
diff --git a/common/djangoapps/config_models/views.py b/common/djangoapps/config_models/views.py
index 3bd693ec59..c9d584f9cb 100644
--- a/common/djangoapps/config_models/views.py
+++ b/common/djangoapps/config_models/views.py
@@ -4,9 +4,10 @@ API view to allow manipulation of configuration models.
from rest_framework.generics import CreateAPIView, RetrieveAPIView
from rest_framework.permissions import DjangoModelPermissions
from rest_framework.authentication import SessionAuthentication
-from rest_framework.serializers import ModelSerializer
from django.db import transaction
+from config_models.utils import get_serializer_class
+
class ReadableOnlyByAuthors(DjangoModelPermissions):
"""Only allow access by users with `add` permissions on the model."""
@@ -58,13 +59,7 @@ class ConfigurationModelCurrentAPIView(AtomicMixin, CreateAPIView, RetrieveAPIVi
def get_serializer_class(self):
if self.serializer_class is None:
- class AutoConfigModelSerializer(ModelSerializer):
- """Serializer class for configuration models."""
- class Meta(object):
- """Meta information for AutoConfigModelSerializer."""
- model = self.model
-
- self.serializer_class = AutoConfigModelSerializer
+ self.serializer_class = get_serializer_class(self.model)
return self.serializer_class
diff --git a/common/djangoapps/contentserver/middleware.py b/common/djangoapps/contentserver/middleware.py
index 8b96c6b536..29eed2e048 100644
--- a/common/djangoapps/contentserver/middleware.py
+++ b/common/djangoapps/contentserver/middleware.py
@@ -3,12 +3,11 @@ Middleware to serve assets.
"""
import logging
-
import datetime
import newrelic.agent
from django.http import (
HttpResponse, HttpResponseNotModified, HttpResponseForbidden,
- HttpResponseBadRequest, HttpResponseNotFound)
+ HttpResponseBadRequest, HttpResponseNotFound, HttpResponsePermanentRedirect)
from student.models import CourseEnrollment
from contentserver.models import CourseAssetCacheTtlConfig, CdnUserAgentsConfig
@@ -30,32 +29,54 @@ HTTP_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT"
class StaticContentServer(object):
+ """
+ Serves course assets to end users. Colloquially referred to as "contentserver."
+ """
def is_asset_request(self, request):
"""Determines whether the given request is an asset request"""
return (
request.path.startswith('/' + XASSET_LOCATION_TAG + '/')
or
request.path.startswith('/' + AssetLocator.CANONICAL_NAMESPACE)
+ or
+ StaticContent.is_versioned_asset_path(request.path)
)
def process_request(self, request):
"""Process the given request"""
+ asset_path = request.path
+
if self.is_asset_request(request):
# Make sure we can convert this request into a location.
- if AssetLocator.CANONICAL_NAMESPACE in request.path:
- request.path = request.path.replace('block/', 'block@', 1)
+ if AssetLocator.CANONICAL_NAMESPACE in asset_path:
+ asset_path = asset_path.replace('block/', 'block@', 1)
+
+ # If this is a versioned request, pull out the digest and chop off the prefix.
+ requested_digest = None
+ if StaticContent.is_versioned_asset_path(asset_path):
+ requested_digest, asset_path = StaticContent.parse_versioned_asset_path(asset_path)
+
+ # Make sure we have a valid location value for this asset.
try:
- loc = StaticContent.get_location_from_path(request.path)
+ loc = StaticContent.get_location_from_path(asset_path)
except (InvalidLocationError, InvalidKeyError):
return HttpResponseBadRequest()
- # Try and load the asset.
- content = None
+ # Attempt to load the asset to make sure it exists, and grab the asset digest
+ # if we're able to load it.
+ actual_digest = None
try:
content = self.load_asset_from_location(loc)
+ actual_digest = getattr(content, "content_digest", None)
except (ItemNotFoundError, NotFoundError):
return HttpResponseNotFound()
+ # If this was a versioned asset, and the digest doesn't match, redirect
+ # them to the actual version.
+ if requested_digest is not None and actual_digest is not None and (actual_digest != requested_digest):
+ actual_asset_path = StaticContent.add_version_to_asset_path(asset_path, actual_digest)
+ return HttpResponsePermanentRedirect(actual_asset_path)
+
# Set the basics for this request. Make sure that the course key for this
# asset has a run, which old-style courses do not. Otherwise, this will
# explode when the key is serialized to be sent to NR.
diff --git a/common/djangoapps/contentserver/test/test_contentserver.py b/common/djangoapps/contentserver/test/test_contentserver.py
index c68bc5a63c..52bdf83d2d 100644
--- a/common/djangoapps/contentserver/test/test_contentserver.py
+++ b/common/djangoapps/contentserver/test/test_contentserver.py
@@ -16,9 +16,13 @@ from django.test.utils import override_settings
from mock import patch
from xmodule.contentstore.django import contentstore
+from xmodule.contentstore.content import StaticContent
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.xml_importer import import_course_from_xml
+from xmodule.assetstore.assetmgr import AssetManager
+from opaque_keys import InvalidKeyError
+from xmodule.modulestore.exceptions import ItemNotFoundError
from contentserver.middleware import parse_range_header, HTTP_DATE_FORMAT, StaticContentServer
from student.models import CourseEnrollment
@@ -28,9 +32,24 @@ log = logging.getLogger(__name__)
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
-
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
+FAKE_MD5_HASH = 'ffffffffffffffffffffffffffffffff'
+
+
+def get_versioned_asset_url(asset_path):
+ """
+ Creates a versioned asset URL.
+ """
+ try:
+ locator = StaticContent.get_location_from_path(asset_path)
+ content = AssetManager.find(locator, as_stream=True)
+ return StaticContent.add_version_to_asset_path(asset_path, content.content_digest)
+ except (InvalidKeyError, ItemNotFoundError):
+ pass
+
+ return asset_path
+
@ddt.ddt
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
@@ -54,13 +73,15 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
)
# A locked asset
- cls.locked_asset = cls.course_key.make_asset_key('asset', 'sample_static.txt')
+ cls.locked_asset = cls.course_key.make_asset_key('asset', 'sample_static.html')
cls.url_locked = unicode(cls.locked_asset)
+ cls.url_locked_versioned = get_versioned_asset_url(cls.url_locked)
cls.contentstore.set_attr(cls.locked_asset, 'locked', True)
# An unlocked asset
cls.unlocked_asset = cls.course_key.make_asset_key('asset', 'another_static.txt')
cls.url_unlocked = unicode(cls.unlocked_asset)
+ cls.url_unlocked_versioned = get_versioned_asset_url(cls.url_unlocked)
cls.length_unlocked = cls.contentstore.get_attr(cls.unlocked_asset, 'length')
def setUp(self):
@@ -81,6 +102,37 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
resp = self.client.get(self.url_unlocked)
self.assertEqual(resp.status_code, 200)
+ def test_unlocked_versioned_asset(self):
+ """
+ Test that unlocked assets that are versioned are being served.
+ """
+ self.client.logout()
+ resp = self.client.get(self.url_unlocked_versioned)
+ self.assertEqual(resp.status_code, 200)
+
+ def test_unlocked_versioned_asset_with_nonexistent_version(self):
+ """
+ Test that unlocked assets that are versioned, but have a nonexistent version,
+ are sent back as a 301 redirect which tells the caller the correct URL.
+ """
+ url_unlocked_versioned_old = StaticContent.add_version_to_asset_path(self.url_unlocked, FAKE_MD5_HASH)
+
+ self.client.logout()
+ resp = self.client.get(url_unlocked_versioned_old)
+ self.assertEqual(resp.status_code, 301)
+ self.assertTrue(resp.url.endswith(self.url_unlocked_versioned)) # pylint: disable=no-member
+
+ def test_locked_versioned_asset(self):
+ """
+ Test that locked assets that are versioned are being served.
+ """
+ CourseEnrollment.enroll(self.non_staff_usr, self.course_key)
+ self.assertTrue(CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key))
+
+ self.client.login(username=self.non_staff_usr, password='test')
+ resp = self.client.get(self.url_locked_versioned)
+ self.assertEqual(resp.status_code, 200)
+
def test_locked_asset_not_logged_in(self):
"""
Test that locked assets behave appropriately in case the user is not
diff --git a/common/djangoapps/pipeline_mako/templates/static_content.html b/common/djangoapps/pipeline_mako/templates/static_content.html
index 4d623b5390..1f3a73788c 100644
--- a/common/djangoapps/pipeline_mako/templates/static_content.html
+++ b/common/djangoapps/pipeline_mako/templates/static_content.html
@@ -166,7 +166,7 @@ else:
%>%def>
<%def name="show_language_selector()"><%
- return settings.FEATURES.get('SHOW_LANGUAGE_SELECTOR', False)
+ return get_value('SHOW_LANGUAGE_SELECTOR', settings.FEATURES.get('SHOW_LANGUAGE_SELECTOR', False))
%>%def>
<%def name="get_released_languages()"><%
diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py
index d98bea1b90..e2a8fe5069 100644
--- a/common/djangoapps/static_replace/__init__.py
+++ b/common/djangoapps/static_replace/__init__.py
@@ -13,6 +13,7 @@ from xmodule.contentstore.content import StaticContent
from opaque_keys.edx.locator import AssetLocator
log = logging.getLogger(__name__)
+XBLOCK_STATIC_RESOURCE_PREFIX = '/static/xblock'
def _url_replace_regex(prefix):
@@ -109,6 +110,13 @@ def process_static_urls(text, replacement_function, data_dir=None):
prefix = match.group('prefix')
quote = match.group('quote')
rest = match.group('rest')
+
+ # Don't rewrite XBlock resource links. Probably wasn't a good idea that /static
+ # works for actual static assets and for magical course asset URLs....
+ full_url = prefix + rest
+ if full_url.startswith(XBLOCK_STATIC_RESOURCE_PREFIX):
+ return original
+
return replacement_function(original, prefix, quote, rest)
return re.sub(
diff --git a/common/djangoapps/static_replace/test/test_static_replace.py b/common/djangoapps/static_replace/test/test_static_replace.py
index bc203a85bc..04a562a771 100644
--- a/common/djangoapps/static_replace/test/test_static_replace.py
+++ b/common/djangoapps/static_replace/test/test_static_replace.py
@@ -1,12 +1,13 @@
+# -*- coding: utf-8 -*-
"""Tests for static_replace"""
-from urllib import quote_plus
-
import ddt
import re
+
+from django.utils.http import urlquote, urlencode
+from urlparse import urlparse, urlunparse, parse_qsl
from PIL import Image
from cStringIO import StringIO
-
from nose.tools import assert_equals, assert_true, assert_false # pylint: disable=no-name-in-module
from static_replace import (
replace_static_urls,
@@ -16,7 +17,6 @@ from static_replace import (
make_static_urls_absolute
)
from mock import patch, Mock
-
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
@@ -25,12 +25,29 @@ from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls
from xmodule.modulestore.xml import XMLModuleStore
+from xmodule.modulestore.exceptions import ItemNotFoundError
+from xmodule.exceptions import NotFoundError
+from xmodule.assetstore.assetmgr import AssetManager
DATA_DIRECTORY = 'data_dir'
COURSE_KEY = SlashSeparatedCourseKey('org', 'course', 'run')
STATIC_SOURCE = '"/static/file.png"'
+def encode_unicode_characters_in_url(url):
+ """
+ Encodes all Unicode characters to their percent-encoding representation
+ in both the path portion and query parameter portion of the given URL.
+ """
+ scheme, netloc, path, params, query, fragment = urlparse(url)
+ query_params = parse_qsl(query)
+ updated_query_params = []
+ for query_name, query_val in query_params:
+ updated_query_params.append((query_name, urlquote(query_val)))
+
+ return urlunparse((scheme, netloc, urlquote(path, '/:+@'), params, urlencode(query_params), fragment))
+
+
def test_multi_replace():
course_source = '"/course/file.png"'
@@ -179,6 +196,21 @@ def test_regex():
assert_false(re.match(regex, s))
+@patch('static_replace.staticfiles_storage', autospec=True)
+@patch('static_replace.modulestore', autospec=True)
+def test_static_url_with_xblock_resource(mock_modulestore, mock_storage):
+ """
+ Make sure that for URLs with XBlock resource URL, which start with /static/,
+ we don't rewrite them.
+ """
+ mock_storage.exists.return_value = False
+ mock_modulestore.return_value = Mock(MongoModuleStore)
+
+ pre_text = 'EMBED src ="/static/xblock/resources/babys_first.lil_xblock/public/images/pacifier.png"'
+ post_text = pre_text
+ assert_equals(post_text, replace_static_urls(pre_text, DATA_DIRECTORY, COURSE_KEY))
+
+
@ddt.ddt
class CanonicalContentTest(SharedModuleStoreTestCase):
"""
@@ -202,7 +234,7 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
cls.courses[prefix] = CourseFactory.create(org='a', course='b', run=prefix)
# Create an unlocked image.
- unlock_content = cls.create_image(prefix, (32, 32), 'blue', '{}_unlock.png')
+ unlock_content = cls.create_image(prefix, (32, 32), 'blue', u'{}_ünlöck.png')
# Create a locked image.
lock_content = cls.create_image(prefix, (32, 32), 'green', '{}_lock.png', locked=True)
@@ -212,14 +244,14 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
contentstore().generate_thumbnail(lock_content, dimensions=(16, 16))
# Create an unlocked image in a subdirectory.
- cls.create_image(prefix, (1, 1), 'red', 'special/{}_unlock.png')
+ cls.create_image(prefix, (1, 1), 'red', u'special/{}_ünlöck.png')
# Create a locked image in a subdirectory.
cls.create_image(prefix, (1, 1), 'yellow', 'special/{}_lock.png', locked=True)
# Create an unlocked image with funky characters in the name.
- cls.create_image(prefix, (1, 1), 'black', 'weird {}_unlock.png')
- cls.create_image(prefix, (1, 1), 'black', 'special/weird {}_unlock.png')
+ cls.create_image(prefix, (1, 1), 'black', u'weird {}_ünlöck.png')
+ cls.create_image(prefix, (1, 1), 'black', u'special/weird {}_ünlöck.png')
# Create an HTML file to test extension exclusion, and create a control file.
cls.create_arbitrary_content(prefix, '{}_not_excluded.htm')
@@ -227,6 +259,24 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
cls.create_arbitrary_content(prefix, 'special/{}_not_excluded.htm')
cls.create_arbitrary_content(prefix, 'special/{}_excluded.html')
+ @classmethod
+ def get_content_digest_for_asset_path(cls, prefix, path):
+ """
+ Takes an unprocessed asset path, parses it just enough to try and find the
+ asset it refers to, and returns the content digest of that asset if it exists.
+ """
+
+ # Parse the path as if it was potentially a relative URL with query parameters,
+ # or an absolute URL, etc. Only keep the path because that's all we need.
+ _, _, relative_path, _, _, _ = urlparse(path)
+ asset_key = StaticContent.get_asset_key_from_path(cls.courses[prefix].id, relative_path)
+
+ try:
+ content = AssetManager.find(asset_key, as_stream=True)
+ return content.content_digest
+ except (ItemNotFoundError, NotFoundError):
+ return None
+
@classmethod
def create_image(cls, prefix, dimensions, color, name, locked=False):
"""
@@ -277,100 +327,100 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
@ddt.data(
# No leading slash.
- (u'', u'{prfx}_unlock.png', u'/{asset}@{prfx}_unlock.png', 1),
+ (u'', u'{prfx}_ünlöck.png', u'/{asset}@{prfx}_ünlöck.png', 1),
(u'', u'{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
- (u'', u'weird {prfx}_unlock.png', u'/{asset}@weird_{prfx}_unlock.png', 1),
- (u'', u'{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
+ (u'', u'weird {prfx}_ünlöck.png', u'/{asset}@weird_{prfx}_ünlöck.png', 1),
+ (u'', u'{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'', u'{prfx}_not_excluded.htm', u'/{asset}@{prfx}_not_excluded.htm', 1),
- (u'dev', u'{prfx}_unlock.png', u'//dev/{asset}@{prfx}_unlock.png', 1),
+ (u'dev', u'{prfx}_ünlöck.png', u'//dev/{asset}@{prfx}_ünlöck.png', 1),
(u'dev', u'{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
- (u'dev', u'weird {prfx}_unlock.png', u'//dev/{asset}@weird_{prfx}_unlock.png', 1),
- (u'dev', u'{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
+ (u'dev', u'weird {prfx}_ünlöck.png', u'//dev/{asset}@weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'dev', u'{prfx}_not_excluded.htm', u'//dev/{asset}@{prfx}_not_excluded.htm', 1),
# No leading slash with subdirectory. This ensures we properly substitute slashes.
- (u'', u'special/{prfx}_unlock.png', u'/{asset}@special_{prfx}_unlock.png', 1),
+ (u'', u'special/{prfx}_ünlöck.png', u'/{asset}@special_{prfx}_ünlöck.png', 1),
(u'', u'special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
- (u'', u'special/weird {prfx}_unlock.png', u'/{asset}@special_weird_{prfx}_unlock.png', 1),
- (u'', u'special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
+ (u'', u'special/weird {prfx}_ünlöck.png', u'/{asset}@special_weird_{prfx}_ünlöck.png', 1),
+ (u'', u'special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'', u'special/{prfx}_not_excluded.htm', u'/{asset}@special_{prfx}_not_excluded.htm', 1),
- (u'dev', u'special/{prfx}_unlock.png', u'//dev/{asset}@special_{prfx}_unlock.png', 1),
+ (u'dev', u'special/{prfx}_ünlöck.png', u'//dev/{asset}@special_{prfx}_ünlöck.png', 1),
(u'dev', u'special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
- (u'dev', u'special/weird {prfx}_unlock.png', u'//dev/{asset}@special_weird_{prfx}_unlock.png', 1),
- (u'dev', u'special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
+ (u'dev', u'special/weird {prfx}_ünlöck.png', u'//dev/{asset}@special_weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'dev', u'special/{prfx}_not_excluded.htm', u'//dev/{asset}@special_{prfx}_not_excluded.htm', 1),
# Leading slash.
- (u'', u'/{prfx}_unlock.png', u'/{asset}@{prfx}_unlock.png', 1),
+ (u'', u'/{prfx}_ünlöck.png', u'/{asset}@{prfx}_ünlöck.png', 1),
(u'', u'/{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
- (u'', u'/weird {prfx}_unlock.png', u'/{asset}@weird_{prfx}_unlock.png', 1),
- (u'', u'/{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
+ (u'', u'/weird {prfx}_ünlöck.png', u'/{asset}@weird_{prfx}_ünlöck.png', 1),
+ (u'', u'/{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'', u'/{prfx}_not_excluded.htm', u'/{asset}@{prfx}_not_excluded.htm', 1),
- (u'dev', u'/{prfx}_unlock.png', u'//dev/{asset}@{prfx}_unlock.png', 1),
+ (u'dev', u'/{prfx}_ünlöck.png', u'//dev/{asset}@{prfx}_ünlöck.png', 1),
(u'dev', u'/{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
- (u'dev', u'/weird {prfx}_unlock.png', u'//dev/{asset}@weird_{prfx}_unlock.png', 1),
- (u'dev', u'/{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
+ (u'dev', u'/weird {prfx}_ünlöck.png', u'//dev/{asset}@weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'/{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'dev', u'/{prfx}_not_excluded.htm', u'//dev/{asset}@{prfx}_not_excluded.htm', 1),
# Leading slash with subdirectory. This ensures we properly substitute slashes.
- (u'', u'/special/{prfx}_unlock.png', u'/{asset}@special_{prfx}_unlock.png', 1),
+ (u'', u'/special/{prfx}_ünlöck.png', u'/{asset}@special_{prfx}_ünlöck.png', 1),
(u'', u'/special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
- (u'', u'/special/weird {prfx}_unlock.png', u'/{asset}@special_weird_{prfx}_unlock.png', 1),
- (u'', u'/special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
+ (u'', u'/special/weird {prfx}_ünlöck.png', u'/{asset}@special_weird_{prfx}_ünlöck.png', 1),
+ (u'', u'/special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'', u'/special/{prfx}_not_excluded.htm', u'/{asset}@special_{prfx}_not_excluded.htm', 1),
- (u'dev', u'/special/{prfx}_unlock.png', u'//dev/{asset}@special_{prfx}_unlock.png', 1),
+ (u'dev', u'/special/{prfx}_ünlöck.png', u'//dev/{asset}@special_{prfx}_ünlöck.png', 1),
(u'dev', u'/special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
- (u'dev', u'/special/weird {prfx}_unlock.png', u'//dev/{asset}@special_weird_{prfx}_unlock.png', 1),
- (u'dev', u'/special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
+ (u'dev', u'/special/weird {prfx}_ünlöck.png', u'//dev/{asset}@special_weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'/special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'dev', u'/special/{prfx}_not_excluded.htm', u'//dev/{asset}@special_{prfx}_not_excluded.htm', 1),
# Static path.
- (u'', u'/static/{prfx}_unlock.png', u'/{asset}@{prfx}_unlock.png', 1),
+ (u'', u'/static/{prfx}_ünlöck.png', u'/{asset}@{prfx}_ünlöck.png', 1),
(u'', u'/static/{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
- (u'', u'/static/weird {prfx}_unlock.png', u'/{asset}@weird_{prfx}_unlock.png', 1),
- (u'', u'/static/{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
+ (u'', u'/static/weird {prfx}_ünlöck.png', u'/{asset}@weird_{prfx}_ünlöck.png', 1),
+ (u'', u'/static/{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'', u'/static/{prfx}_not_excluded.htm', u'/{asset}@{prfx}_not_excluded.htm', 1),
- (u'dev', u'/static/{prfx}_unlock.png', u'//dev/{asset}@{prfx}_unlock.png', 1),
+ (u'dev', u'/static/{prfx}_ünlöck.png', u'//dev/{asset}@{prfx}_ünlöck.png', 1),
(u'dev', u'/static/{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
- (u'dev', u'/static/weird {prfx}_unlock.png', u'//dev/{asset}@weird_{prfx}_unlock.png', 1),
- (u'dev', u'/static/{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
+ (u'dev', u'/static/weird {prfx}_ünlöck.png', u'//dev/{asset}@weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'/static/{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
(u'dev', u'/static/{prfx}_not_excluded.htm', u'//dev/{asset}@{prfx}_not_excluded.htm', 1),
# Static path with subdirectory. This ensures we properly substitute slashes.
- (u'', u'/static/special/{prfx}_unlock.png', u'/{asset}@special_{prfx}_unlock.png', 1),
+ (u'', u'/static/special/{prfx}_ünlöck.png', u'/{asset}@special_{prfx}_ünlöck.png', 1),
(u'', u'/static/special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
- (u'', u'/static/special/weird {prfx}_unlock.png', u'/{asset}@special_weird_{prfx}_unlock.png', 1),
- (u'', u'/static/special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
+ (u'', u'/static/special/weird {prfx}_ünlöck.png', u'/{asset}@special_weird_{prfx}_ünlöck.png', 1),
+ (u'', u'/static/special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'', u'/static/special/{prfx}_not_excluded.htm', u'/{asset}@special_{prfx}_not_excluded.htm', 1),
- (u'dev', u'/static/special/{prfx}_unlock.png', u'//dev/{asset}@special_{prfx}_unlock.png', 1),
+ (u'dev', u'/static/special/{prfx}_ünlöck.png', u'//dev/{asset}@special_{prfx}_ünlöck.png', 1),
(u'dev', u'/static/special/{prfx}_lock.png', u'/{asset}@special_{prfx}_lock.png', 1),
- (u'dev', u'/static/special/weird {prfx}_unlock.png', u'//dev/{asset}@special_weird_{prfx}_unlock.png', 1),
- (u'dev', u'/static/special/{prfx}_excluded.html', u'/{asset}@special_{prfx}_excluded.html', 1),
+ (u'dev', u'/static/special/weird {prfx}_ünlöck.png', u'//dev/{asset}@special_weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'/static/special/{prfx}_excluded.html', u'/{base_asset}@special_{prfx}_excluded.html', 1),
(u'dev', u'/static/special/{prfx}_not_excluded.htm', u'//dev/{asset}@special_{prfx}_not_excluded.htm', 1),
# Static path with query parameter.
(
u'',
- u'/static/{prfx}_unlock.png?foo=/static/{prfx}_lock.png',
- u'/{asset}@{prfx}_unlock.png?foo={encoded_asset}{prfx}_lock.png',
+ u'/static/{prfx}_ünlöck.png?foo=/static/{prfx}_lock.png',
+ u'/{asset}@{prfx}_ünlöck.png?foo={encoded_asset}{prfx}_lock.png',
2
),
(
u'',
- u'/static/{prfx}_lock.png?foo=/static/{prfx}_unlock.png',
- u'/{asset}@{prfx}_lock.png?foo={encoded_asset}{prfx}_unlock.png',
+ u'/static/{prfx}_lock.png?foo=/static/{prfx}_ünlöck.png',
+ u'/{asset}@{prfx}_lock.png?foo={encoded_asset}{prfx}_ünlöck.png',
2
),
(
u'',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_excluded.html',
- u'/{asset}@{prfx}_excluded.html?foo={encoded_asset}{prfx}_excluded.html',
+ u'/{base_asset}@{prfx}_excluded.html?foo={encoded_base_asset}{prfx}_excluded.html',
2
),
(
u'',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_not_excluded.htm',
- u'/{asset}@{prfx}_excluded.html?foo={encoded_asset}{prfx}_not_excluded.htm',
+ u'/{base_asset}@{prfx}_excluded.html?foo={encoded_asset}{prfx}_not_excluded.htm',
2
),
(
u'',
u'/static/{prfx}_not_excluded.htm?foo=/static/{prfx}_excluded.html',
- u'/{asset}@{prfx}_not_excluded.htm?foo={encoded_asset}{prfx}_excluded.html',
+ u'/{asset}@{prfx}_not_excluded.htm?foo={encoded_base_asset}{prfx}_excluded.html',
2
),
(
@@ -381,32 +431,32 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
),
(
u'dev',
- u'/static/{prfx}_unlock.png?foo=/static/{prfx}_lock.png',
- u'//dev/{asset}@{prfx}_unlock.png?foo={encoded_asset}{prfx}_lock.png',
+ u'/static/{prfx}_ünlöck.png?foo=/static/{prfx}_lock.png',
+ u'//dev/{asset}@{prfx}_ünlöck.png?foo={encoded_asset}{prfx}_lock.png',
2
),
(
u'dev',
- u'/static/{prfx}_lock.png?foo=/static/{prfx}_unlock.png',
- u'/{asset}@{prfx}_lock.png?foo={encoded_base_url}{encoded_asset}{prfx}_unlock.png',
+ u'/static/{prfx}_lock.png?foo=/static/{prfx}_ünlöck.png',
+ u'/{asset}@{prfx}_lock.png?foo={encoded_base_url}{encoded_asset}{prfx}_ünlöck.png',
2
),
(
u'dev',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_excluded.html',
- u'/{asset}@{prfx}_excluded.html?foo={encoded_asset}{prfx}_excluded.html',
+ u'/{base_asset}@{prfx}_excluded.html?foo={encoded_base_asset}{prfx}_excluded.html',
2
),
(
u'dev',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_not_excluded.htm',
- u'/{asset}@{prfx}_excluded.html?foo={encoded_base_url}{encoded_asset}{prfx}_not_excluded.htm',
+ u'/{base_asset}@{prfx}_excluded.html?foo={encoded_base_url}{encoded_asset}{prfx}_not_excluded.htm',
2
),
(
u'dev',
u'/static/{prfx}_not_excluded.htm?foo=/static/{prfx}_excluded.html',
- u'//dev/{asset}@{prfx}_not_excluded.htm?foo={encoded_asset}{prfx}_excluded.html',
+ u'//dev/{asset}@{prfx}_not_excluded.htm?foo={encoded_base_asset}{prfx}_excluded.html',
2
),
(
@@ -416,163 +466,189 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
2
),
# Already asset key.
- (u'', u'/{asset}@{prfx}_unlock.png', u'/{asset}@{prfx}_unlock.png', 1),
- (u'', u'/{asset}@{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
- (u'', u'/{asset}@weird_{prfx}_unlock.png', u'/{asset}@weird_{prfx}_unlock.png', 1),
- (u'', u'/{asset}@{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
- (u'', u'/{asset}@{prfx}_not_excluded.htm', u'/{asset}@{prfx}_not_excluded.htm', 1),
- (u'dev', u'/{asset}@{prfx}_unlock.png', u'//dev/{asset}@{prfx}_unlock.png', 1),
- (u'dev', u'/{asset}@{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
- (u'dev', u'/{asset}@weird_{prfx}_unlock.png', u'//dev/{asset}@weird_{prfx}_unlock.png', 1),
- (u'dev', u'/{asset}@{prfx}_excluded.html', u'/{asset}@{prfx}_excluded.html', 1),
- (u'dev', u'/{asset}@{prfx}_not_excluded.htm', u'//dev/{asset}@{prfx}_not_excluded.htm', 1),
+ (u'', u'/{base_asset}@{prfx}_ünlöck.png', u'/{asset}@{prfx}_ünlöck.png', 1),
+ (u'', u'/{base_asset}@{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
+ (u'', u'/{base_asset}@weird_{prfx}_ünlöck.png', u'/{asset}@weird_{prfx}_ünlöck.png', 1),
+ (u'', u'/{base_asset}@{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
+ (u'', u'/{base_asset}@{prfx}_not_excluded.htm', u'/{asset}@{prfx}_not_excluded.htm', 1),
+ (u'dev', u'/{base_asset}@{prfx}_ünlöck.png', u'//dev/{asset}@{prfx}_ünlöck.png', 1),
+ (u'dev', u'/{base_asset}@{prfx}_lock.png', u'/{asset}@{prfx}_lock.png', 1),
+ (u'dev', u'/{base_asset}@weird_{prfx}_ünlöck.png', u'//dev/{asset}@weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'/{base_asset}@{prfx}_excluded.html', u'/{base_asset}@{prfx}_excluded.html', 1),
+ (u'dev', u'/{base_asset}@{prfx}_not_excluded.htm', u'//dev/{asset}@{prfx}_not_excluded.htm', 1),
# Old, c4x-style path.
- (u'', u'/{c4x}/{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
+ (u'', u'/{c4x}/{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'', u'/{c4x}/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'', u'/{c4x}/weird_{prfx}_lock.png', u'/{c4x}/weird_{prfx}_lock.png', 1),
(u'', u'/{c4x}/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'', u'/{c4x}/{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
- (u'dev', u'/{c4x}/{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
+ (u'dev', u'/{c4x}/{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'dev', u'/{c4x}/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
- (u'dev', u'/{c4x}/weird_{prfx}_unlock.png', u'/{c4x}/weird_{prfx}_unlock.png', 1),
+ (u'dev', u'/{c4x}/weird_{prfx}_ünlöck.png', u'/{c4x}/weird_{prfx}_ünlöck.png', 1),
(u'dev', u'/{c4x}/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/{c4x}/{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
# Thumbnails.
- (u'', u'/{th_key}@{prfx}_unlock-{th_ext}', u'/{th_key}@{prfx}_unlock-{th_ext}', 1),
- (u'', u'/{th_key}@{prfx}_lock-{th_ext}', u'/{th_key}@{prfx}_lock-{th_ext}', 1),
- (u'dev', u'/{th_key}@{prfx}_unlock-{th_ext}', u'//dev/{th_key}@{prfx}_unlock-{th_ext}', 1),
- (u'dev', u'/{th_key}@{prfx}_lock-{th_ext}', u'//dev/{th_key}@{prfx}_lock-{th_ext}', 1),
+ (u'', u'/{base_th_key}@{prfx}_ünlöck-{th_ext}', u'/{th_key}@{prfx}_ünlöck-{th_ext}', 1),
+ (u'', u'/{base_th_key}@{prfx}_lock-{th_ext}', u'/{th_key}@{prfx}_lock-{th_ext}', 1),
+ (u'dev', u'/{base_th_key}@{prfx}_ünlöck-{th_ext}', u'//dev/{th_key}@{prfx}_ünlöck-{th_ext}', 1),
+ (u'dev', u'/{base_th_key}@{prfx}_lock-{th_ext}', u'//dev/{th_key}@{prfx}_lock-{th_ext}', 1),
)
@ddt.unpack
def test_canonical_asset_path_with_new_style_assets(self, base_url, start, expected, mongo_calls):
exts = ['.html', '.tm']
- prefix = 'split'
- encoded_base_url = quote_plus('//' + base_url)
- c4x = 'c4x/a/b/asset'
- asset_key = 'asset-v1:a+b+{}+type@asset+block'.format(prefix)
- encoded_asset_key = quote_plus('/asset-v1:a+b+{}+type@asset+block@'.format(prefix))
- th_key = 'asset-v1:a+b+{}+type@thumbnail+block'.format(prefix)
- th_ext = 'png-16x16.jpg'
+ prefix = u'split'
+ encoded_base_url = urlquote(u'//' + base_url)
+ c4x = u'c4x/a/b/asset'
+ base_asset_key = u'asset-v1:a+b+{}+type@asset+block'.format(prefix)
+ adjusted_asset_key = base_asset_key
+ encoded_asset_key = urlquote(u'/asset-v1:a+b+{}+type@asset+block@'.format(prefix))
+ encoded_base_asset_key = encoded_asset_key
+ base_th_key = u'asset-v1:a+b+{}+type@thumbnail+block'.format(prefix)
+ adjusted_th_key = base_th_key
+ th_ext = u'png-16x16.jpg'
start = start.format(
prfx=prefix,
c4x=c4x,
- asset=asset_key,
+ base_asset=base_asset_key,
+ asset=adjusted_asset_key,
encoded_base_url=encoded_base_url,
encoded_asset=encoded_asset_key,
- th_key=th_key,
+ base_th_key=base_th_key,
+ th_key=adjusted_th_key,
th_ext=th_ext
)
+
+ # Adjust for content digest. This gets dicey quickly and we have to order our steps:
+ # - replace format markets because they have curly braces
+ # - encode Unicode characters to percent-encoded
+ # - finally shove back in our regex patterns
+ digest = CanonicalContentTest.get_content_digest_for_asset_path(prefix, start)
+ if digest:
+ adjusted_asset_key = u'assets/courseware/MARK/asset-v1:a+b+{}+type@asset+block'.format(prefix)
+ adjusted_th_key = u'assets/courseware/MARK/asset-v1:a+b+{}+type@thumbnail+block'.format(prefix)
+ encoded_asset_key = u'/assets/courseware/MARK/asset-v1:a+b+{}+type@asset+block@'.format(prefix)
+ encoded_asset_key = urlquote(encoded_asset_key)
+
expected = expected.format(
prfx=prefix,
c4x=c4x,
- asset=asset_key,
+ base_asset=base_asset_key,
+ asset=adjusted_asset_key,
encoded_base_url=encoded_base_url,
encoded_asset=encoded_asset_key,
- th_key=th_key,
- th_ext=th_ext
+ base_th_key=base_th_key,
+ th_key=adjusted_th_key,
+ th_ext=th_ext,
+ encoded_base_asset=encoded_base_asset_key,
)
+ expected = encode_unicode_characters_in_url(expected)
+ expected = expected.replace('MARK', '[a-f0-9]{32}')
+ expected = expected.replace('+', r'\+').replace('?', r'\?')
+
with check_mongo_calls(mongo_calls):
asset_path = StaticContent.get_canonicalized_asset_path(self.courses[prefix].id, start, base_url, exts)
- self.assertEqual(asset_path, expected)
+ print expected
+ print asset_path
+ self.assertIsNotNone(re.match(expected, asset_path))
@ddt.data(
# No leading slash.
- (u'', u'{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
+ (u'', u'{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'', u'{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
- (u'', u'weird {prfx}_unlock.png', u'/{c4x}/weird_{prfx}_unlock.png', 1),
- (u'', u'{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
+ (u'', u'weird {prfx}_ünlöck.png', u'/{c4x}/weird_{prfx}_ünlöck.png', 1),
+ (u'', u'{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'', u'{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
- (u'dev', u'{prfx}_unlock.png', u'//dev/{c4x}/{prfx}_unlock.png', 1),
+ (u'dev', u'{prfx}_ünlöck.png', u'//dev/{c4x}/{prfx}_ünlöck.png', 1),
(u'dev', u'{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
- (u'dev', u'weird {prfx}_unlock.png', u'//dev/{c4x}/weird_{prfx}_unlock.png', 1),
- (u'dev', u'{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
+ (u'dev', u'weird {prfx}_ünlöck.png', u'//dev/{c4x}/weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'dev', u'{prfx}_not_excluded.htm', u'//dev/{c4x}/{prfx}_not_excluded.htm', 1),
# No leading slash with subdirectory. This ensures we probably substitute slashes.
- (u'', u'special/{prfx}_unlock.png', u'/{c4x}/special_{prfx}_unlock.png', 1),
+ (u'', u'special/{prfx}_ünlöck.png', u'/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'', u'special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
- (u'', u'special/weird {prfx}_unlock.png', u'/{c4x}/special_weird_{prfx}_unlock.png', 1),
- (u'', u'special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
+ (u'', u'special/weird {prfx}_ünlöck.png', u'/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
+ (u'', u'special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'', u'special/{prfx}_not_excluded.htm', u'/{c4x}/special_{prfx}_not_excluded.htm', 1),
- (u'dev', u'special/{prfx}_unlock.png', u'//dev/{c4x}/special_{prfx}_unlock.png', 1),
+ (u'dev', u'special/{prfx}_ünlöck.png', u'//dev/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'dev', u'special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
- (u'dev', u'special/weird {prfx}_unlock.png', u'//dev/{c4x}/special_weird_{prfx}_unlock.png', 1),
- (u'dev', u'special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
+ (u'dev', u'special/weird {prfx}_ünlöck.png', u'//dev/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'dev', u'special/{prfx}_not_excluded.htm', u'//dev/{c4x}/special_{prfx}_not_excluded.htm', 1),
# Leading slash.
- (u'', u'/{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
+ (u'', u'/{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'', u'/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
- (u'', u'/weird {prfx}_unlock.png', u'/{c4x}/weird_{prfx}_unlock.png', 1),
- (u'', u'/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
+ (u'', u'/weird {prfx}_ünlöck.png', u'/{c4x}/weird_{prfx}_ünlöck.png', 1),
+ (u'', u'/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'', u'/{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
- (u'dev', u'/{prfx}_unlock.png', u'//dev/{c4x}/{prfx}_unlock.png', 1),
+ (u'dev', u'/{prfx}_ünlöck.png', u'//dev/{c4x}/{prfx}_ünlöck.png', 1),
(u'dev', u'/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
- (u'dev', u'/weird {prfx}_unlock.png', u'//dev/{c4x}/weird_{prfx}_unlock.png', 1),
- (u'dev', u'/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
+ (u'dev', u'/weird {prfx}_ünlöck.png', u'//dev/{c4x}/weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/{prfx}_not_excluded.htm', u'//dev/{c4x}/{prfx}_not_excluded.htm', 1),
# Leading slash with subdirectory. This ensures we properly substitute slashes.
- (u'', u'/special/{prfx}_unlock.png', u'/{c4x}/special_{prfx}_unlock.png', 1),
+ (u'', u'/special/{prfx}_ünlöck.png', u'/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'', u'/special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
- (u'', u'/special/weird {prfx}_unlock.png', u'/{c4x}/special_weird_{prfx}_unlock.png', 1),
- (u'', u'/special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
+ (u'', u'/special/weird {prfx}_ünlöck.png', u'/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
+ (u'', u'/special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'', u'/special/{prfx}_not_excluded.htm', u'/{c4x}/special_{prfx}_not_excluded.htm', 1),
- (u'dev', u'/special/{prfx}_unlock.png', u'//dev/{c4x}/special_{prfx}_unlock.png', 1),
+ (u'dev', u'/special/{prfx}_ünlöck.png', u'//dev/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'dev', u'/special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
- (u'dev', u'/special/weird {prfx}_unlock.png', u'//dev/{c4x}/special_weird_{prfx}_unlock.png', 1),
- (u'dev', u'/special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
+ (u'dev', u'/special/weird {prfx}_ünlöck.png', u'//dev/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'/special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'dev', u'/special/{prfx}_not_excluded.htm', u'//dev/{c4x}/special_{prfx}_not_excluded.htm', 1),
# Static path.
- (u'', u'/static/{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
+ (u'', u'/static/{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'', u'/static/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
- (u'', u'/static/weird {prfx}_unlock.png', u'/{c4x}/weird_{prfx}_unlock.png', 1),
- (u'', u'/static/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
+ (u'', u'/static/weird {prfx}_ünlöck.png', u'/{c4x}/weird_{prfx}_ünlöck.png', 1),
+ (u'', u'/static/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'', u'/static/{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
- (u'dev', u'/static/{prfx}_unlock.png', u'//dev/{c4x}/{prfx}_unlock.png', 1),
+ (u'dev', u'/static/{prfx}_ünlöck.png', u'//dev/{c4x}/{prfx}_ünlöck.png', 1),
(u'dev', u'/static/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
- (u'dev', u'/static/weird {prfx}_unlock.png', u'//dev/{c4x}/weird_{prfx}_unlock.png', 1),
- (u'dev', u'/static/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
+ (u'dev', u'/static/weird {prfx}_ünlöck.png', u'//dev/{c4x}/weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'/static/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/static/{prfx}_not_excluded.htm', u'//dev/{c4x}/{prfx}_not_excluded.htm', 1),
# Static path with subdirectory. This ensures we properly substitute slashes.
- (u'', u'/static/special/{prfx}_unlock.png', u'/{c4x}/special_{prfx}_unlock.png', 1),
+ (u'', u'/static/special/{prfx}_ünlöck.png', u'/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'', u'/static/special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
- (u'', u'/static/special/weird {prfx}_unlock.png', u'/{c4x}/special_weird_{prfx}_unlock.png', 1),
- (u'', u'/static/special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
+ (u'', u'/static/special/weird {prfx}_ünlöck.png', u'/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
+ (u'', u'/static/special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'', u'/static/special/{prfx}_not_excluded.htm', u'/{c4x}/special_{prfx}_not_excluded.htm', 1),
- (u'dev', u'/static/special/{prfx}_unlock.png', u'//dev/{c4x}/special_{prfx}_unlock.png', 1),
+ (u'dev', u'/static/special/{prfx}_ünlöck.png', u'//dev/{c4x}/special_{prfx}_ünlöck.png', 1),
(u'dev', u'/static/special/{prfx}_lock.png', u'/{c4x}/special_{prfx}_lock.png', 1),
- (u'dev', u'/static/special/weird {prfx}_unlock.png', u'//dev/{c4x}/special_weird_{prfx}_unlock.png', 1),
- (u'dev', u'/static/special/{prfx}_excluded.html', u'/{c4x}/special_{prfx}_excluded.html', 1),
+ (u'dev', u'/static/special/weird {prfx}_ünlöck.png', u'//dev/{c4x}/special_weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'/static/special/{prfx}_excluded.html', u'/{base_c4x}/special_{prfx}_excluded.html', 1),
(u'dev', u'/static/special/{prfx}_not_excluded.htm', u'//dev/{c4x}/special_{prfx}_not_excluded.htm', 1),
# Static path with query parameter.
(
u'',
- u'/static/{prfx}_unlock.png?foo=/static/{prfx}_lock.png',
- u'/{c4x}/{prfx}_unlock.png?foo={encoded_c4x}{prfx}_lock.png',
+ u'/static/{prfx}_ünlöck.png?foo=/static/{prfx}_lock.png',
+ u'/{c4x}/{prfx}_ünlöck.png?foo={encoded_c4x}{prfx}_lock.png',
2
),
(
u'',
- u'/static/{prfx}_lock.png?foo=/static/{prfx}_unlock.png',
- u'/{c4x}/{prfx}_lock.png?foo={encoded_c4x}{prfx}_unlock.png',
+ u'/static/{prfx}_lock.png?foo=/static/{prfx}_ünlöck.png',
+ u'/{c4x}/{prfx}_lock.png?foo={encoded_c4x}{prfx}_ünlöck.png',
2
),
(
u'',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_excluded.html',
- u'/{c4x}/{prfx}_excluded.html?foo={encoded_c4x}{prfx}_excluded.html',
+ u'/{base_c4x}/{prfx}_excluded.html?foo={encoded_base_c4x}{prfx}_excluded.html',
2
),
(
u'',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_not_excluded.htm',
- u'/{c4x}/{prfx}_excluded.html?foo={encoded_c4x}{prfx}_not_excluded.htm',
+ u'/{base_c4x}/{prfx}_excluded.html?foo={encoded_c4x}{prfx}_not_excluded.htm',
2
),
(
u'',
u'/static/{prfx}_not_excluded.htm?foo=/static/{prfx}_excluded.html',
- u'/{c4x}/{prfx}_not_excluded.htm?foo={encoded_c4x}{prfx}_excluded.html',
+ u'/{c4x}/{prfx}_not_excluded.htm?foo={encoded_base_c4x}{prfx}_excluded.html',
2
),
(
@@ -583,32 +659,32 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
),
(
u'dev',
- u'/static/{prfx}_unlock.png?foo=/static/{prfx}_lock.png',
- u'//dev/{c4x}/{prfx}_unlock.png?foo={encoded_c4x}{prfx}_lock.png',
+ u'/static/{prfx}_ünlöck.png?foo=/static/{prfx}_lock.png',
+ u'//dev/{c4x}/{prfx}_ünlöck.png?foo={encoded_c4x}{prfx}_lock.png',
2
),
(
u'dev',
- u'/static/{prfx}_lock.png?foo=/static/{prfx}_unlock.png',
- u'/{c4x}/{prfx}_lock.png?foo={encoded_base_url}{encoded_c4x}{prfx}_unlock.png',
+ u'/static/{prfx}_lock.png?foo=/static/{prfx}_ünlöck.png',
+ u'/{c4x}/{prfx}_lock.png?foo={encoded_base_url}{encoded_c4x}{prfx}_ünlöck.png',
2
),
(
u'dev',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_excluded.html',
- u'/{c4x}/{prfx}_excluded.html?foo={encoded_c4x}{prfx}_excluded.html',
+ u'/{base_c4x}/{prfx}_excluded.html?foo={encoded_base_c4x}{prfx}_excluded.html',
2
),
(
u'dev',
u'/static/{prfx}_excluded.html?foo=/static/{prfx}_not_excluded.htm',
- u'/{c4x}/{prfx}_excluded.html?foo={encoded_base_url}{encoded_c4x}{prfx}_not_excluded.htm',
+ u'/{base_c4x}/{prfx}_excluded.html?foo={encoded_base_url}{encoded_c4x}{prfx}_not_excluded.htm',
2
),
(
u'dev',
u'/static/{prfx}_not_excluded.htm?foo=/static/{prfx}_excluded.html',
- u'//dev/{c4x}/{prfx}_not_excluded.htm?foo={encoded_c4x}{prfx}_excluded.html',
+ u'//dev/{c4x}/{prfx}_not_excluded.htm?foo={encoded_base_c4x}{prfx}_excluded.html',
2
),
(
@@ -618,38 +694,58 @@ class CanonicalContentTest(SharedModuleStoreTestCase):
2
),
# Old, c4x-style path.
- (u'', u'/{c4x}/{prfx}_unlock.png', u'/{c4x}/{prfx}_unlock.png', 1),
+ (u'', u'/{c4x}/{prfx}_ünlöck.png', u'/{c4x}/{prfx}_ünlöck.png', 1),
(u'', u'/{c4x}/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
(u'', u'/{c4x}/weird_{prfx}_lock.png', u'/{c4x}/weird_{prfx}_lock.png', 1),
- (u'', u'/{c4x}/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
+ (u'', u'/{c4x}/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'', u'/{c4x}/{prfx}_not_excluded.htm', u'/{c4x}/{prfx}_not_excluded.htm', 1),
- (u'dev', u'/{c4x}/{prfx}_unlock.png', u'//dev/{c4x}/{prfx}_unlock.png', 1),
+ (u'dev', u'/{c4x}/{prfx}_ünlöck.png', u'//dev/{c4x}/{prfx}_ünlöck.png', 1),
(u'dev', u'/{c4x}/{prfx}_lock.png', u'/{c4x}/{prfx}_lock.png', 1),
- (u'dev', u'/{c4x}/weird_{prfx}_unlock.png', u'//dev/{c4x}/weird_{prfx}_unlock.png', 1),
- (u'dev', u'/{c4x}/{prfx}_excluded.html', u'/{c4x}/{prfx}_excluded.html', 1),
+ (u'dev', u'/{c4x}/weird_{prfx}_ünlöck.png', u'//dev/{c4x}/weird_{prfx}_ünlöck.png', 1),
+ (u'dev', u'/{c4x}/{prfx}_excluded.html', u'/{base_c4x}/{prfx}_excluded.html', 1),
(u'dev', u'/{c4x}/{prfx}_not_excluded.htm', u'//dev/{c4x}/{prfx}_not_excluded.htm', 1),
)
@ddt.unpack
def test_canonical_asset_path_with_c4x_style_assets(self, base_url, start, expected, mongo_calls):
exts = ['.html', '.tm']
prefix = 'old'
- c4x_block = 'c4x/a/b/asset'
- encoded_c4x_block = quote_plus('/' + c4x_block + '/')
- encoded_base_url = quote_plus('//' + base_url)
+ base_c4x_block = 'c4x/a/b/asset'
+ adjusted_c4x_block = base_c4x_block
+ encoded_c4x_block = urlquote('/' + base_c4x_block + '/')
+ encoded_base_url = urlquote('//' + base_url)
+ encoded_base_c4x_block = encoded_c4x_block
start = start.format(
prfx=prefix,
encoded_base_url=encoded_base_url,
- c4x=c4x_block,
- encoded_c4x=encoded_c4x_block
- )
- expected = expected.format(
- prfx=prefix,
- encoded_base_url=encoded_base_url,
- c4x=c4x_block,
+ c4x=base_c4x_block,
encoded_c4x=encoded_c4x_block
)
+ # Adjust for content digest. This gets dicey quickly and we have to order our steps:
+ # - replace format markets because they have curly braces
+ # - encode Unicode characters to percent-encoded
+ # - finally shove back in our regex patterns
+ digest = CanonicalContentTest.get_content_digest_for_asset_path(prefix, start)
+ if digest:
+ adjusted_c4x_block = 'assets/courseware/MARK/c4x/a/b/asset'
+ encoded_c4x_block = urlquote('/' + adjusted_c4x_block + '/')
+
+ expected = expected.format(
+ prfx=prefix,
+ encoded_base_url=encoded_base_url,
+ base_c4x=base_c4x_block,
+ c4x=adjusted_c4x_block,
+ encoded_c4x=encoded_c4x_block,
+ encoded_base_c4x=encoded_base_c4x_block,
+ )
+
+ expected = encode_unicode_characters_in_url(expected)
+ expected = expected.replace('MARK', '[a-f0-9]{32}')
+ expected = expected.replace('+', r'\+').replace('?', r'\?')
+
with check_mongo_calls(mongo_calls):
asset_path = StaticContent.get_canonicalized_asset_path(self.courses[prefix].id, start, base_url, exts)
- self.assertEqual(asset_path, expected)
+ print expected
+ print asset_path
+ self.assertIsNotNone(re.match(expected, asset_path))
diff --git a/common/djangoapps/student/management/commands/manage_user.py b/common/djangoapps/student/management/commands/manage_user.py
index dcb299006e..fed69c7771 100644
--- a/common/djangoapps/student/management/commands/manage_user.py
+++ b/common/djangoapps/student/management/commands/manage_user.py
@@ -23,6 +23,7 @@ class Command(BaseCommand):
parser.add_argument('--remove', dest='is_remove', action='store_true')
parser.add_argument('--superuser', dest='is_superuser', action='store_true')
parser.add_argument('--staff', dest='is_staff', action='store_true')
+ parser.add_argument('--unusable-password', dest='unusable_password', action='store_true')
parser.add_argument('-g', '--groups', nargs='*', default=[])
def _maybe_update(self, user, attribute, new_value):
@@ -68,7 +69,7 @@ class Command(BaseCommand):
user.delete()
@transaction.atomic
- def handle(self, username, email, is_remove, is_staff, is_superuser, groups, *args, **options):
+ def handle(self, username, email, is_remove, is_staff, is_superuser, groups, unusable_password, *args, **options):
if is_remove:
return self._handle_remove(username, email)
@@ -91,6 +92,11 @@ class Command(BaseCommand):
self._maybe_update(user, 'is_staff', is_staff)
self._maybe_update(user, 'is_superuser', is_superuser)
+ # Set unusable password if specified
+ if unusable_password and user.has_usable_password():
+ self.stderr.write(_('Setting unusable password for user "{}"').format(user))
+ user.set_unusable_password()
+
# Ensure the user has a profile
try:
__ = user.profile
diff --git a/common/djangoapps/student/management/tests/test_manage_user.py b/common/djangoapps/student/management/tests/test_manage_user.py
index 1335ee5b14..3b3186e250 100644
--- a/common/djangoapps/student/management/tests/test_manage_user.py
+++ b/common/djangoapps/student/management/tests/test_manage_user.py
@@ -48,6 +48,29 @@ class TestManageUserCommand(TestCase):
call_command('manage_user', TEST_USERNAME, TEST_EMAIL, '--remove')
self.assertEqual([], list(User.objects.all()))
+ def test_unusable_password(self):
+ """
+ Ensure that a user's password is set to an unusable_password.
+ """
+ user = User.objects.create(username=TEST_USERNAME, email=TEST_EMAIL)
+ self.assertEqual([(TEST_USERNAME, TEST_EMAIL)], [(u.username, u.email) for u in User.objects.all()])
+ user.set_password(User.objects.make_random_password())
+ user.save()
+
+ # Run once without passing --unusable-password and make sure the password is usable
+ call_command('manage_user', TEST_USERNAME, TEST_EMAIL)
+ user = User.objects.get(username=TEST_USERNAME, email=TEST_EMAIL)
+ self.assertTrue(user.has_usable_password())
+
+ # Make sure the user now has an unusable_password
+ call_command('manage_user', TEST_USERNAME, TEST_EMAIL, '--unusable-password')
+ user = User.objects.get(username=TEST_USERNAME, email=TEST_EMAIL)
+ self.assertFalse(user.has_usable_password())
+
+ # check idempotency
+ call_command('manage_user', TEST_USERNAME, TEST_EMAIL, '--unusable-password')
+ self.assertFalse(user.has_usable_password())
+
def test_wrong_email(self):
"""
Ensure that the operation is aborted if the username matches an
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index 26a3df4117..018aad8dd8 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -891,7 +891,7 @@ class AnonymousLookupTable(ModuleStoreTestCase):
self.assertEqual(anonymous_id, anonymous_id_for_user(self.user, course2.id, save=False))
-# TODO: Clean up these tests so that they use the ProgramsDataMixin.
+# TODO: Clean up these tests so that they use program factories.
@attr('shard_3')
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.ddt
diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py
index 749a68e122..905b84f096 100644
--- a/common/djangoapps/terrain/ui_helpers.py
+++ b/common/djangoapps/terrain/ui_helpers.py
@@ -59,7 +59,7 @@ REQUIREJS_WAIT = {
# Pages
re.compile(r'^Pages \|'): [
- 'js/models/explicit_url', 'coffee/src/views/tabs',
+ 'js/models/explicit_url', 'js/views/tabs',
'xmodule', 'coffee/src/main', 'xblock/cms.runtime.v1'
],
}
diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py
index ce76ef20c5..5bd8f22200 100644
--- a/common/djangoapps/third_party_auth/models.py
+++ b/common/djangoapps/third_party_auth/models.py
@@ -21,6 +21,7 @@ from social.backends.saml import SAMLAuth, SAMLIdentityProvider
from .lti import LTIAuthBackend, LTI_PARAMS_KEY
from social.exceptions import SocialAuthBaseException
from social.utils import module_member
+from openedx.core.djangoapps.theming.helpers import get_value as get_themed_value
log = logging.getLogger(__name__)
@@ -453,7 +454,7 @@ class SAMLConfiguration(ConfigurationModel):
other_config = json.loads(self.other_config_str)
if name in ("TECHNICAL_CONTACT", "SUPPORT_CONTACT"):
contact = {
- "givenName": "{} Support".format(settings.PLATFORM_NAME),
+ "givenName": "{} Support".format(get_themed_value('PLATFORM_NAME', settings.PLATFORM_NAME)),
"emailAddress": settings.TECH_SUPPORT_EMAIL
}
contact.update(other_config.get(name, {}))
diff --git a/common/lib/xmodule/xmodule/block_metadata_utils.py b/common/lib/xmodule/xmodule/block_metadata_utils.py
new file mode 100644
index 0000000000..3969419597
--- /dev/null
+++ b/common/lib/xmodule/xmodule/block_metadata_utils.py
@@ -0,0 +1,80 @@
+"""
+Simple utility functions that operate on block metadata.
+
+This is a place to put simple functions that operate on block metadata. It
+allows us to share code between the XModuleMixin and CourseOverview and
+BlockStructure.
+"""
+
+
+def url_name_for_block(block):
+ """
+ Given a block, returns the block's URL name.
+
+ Arguments:
+ block (XModuleMixin|CourseOverview|BlockStructureBlockData):
+ Block that is being accessed
+ """
+ return block.location.name
+
+
+def display_name_with_default(block):
+ """
+ Calculates the display name for a block.
+
+ Default to the display_name if it isn't None, else fall back to creating
+ a name based on the URL.
+
+ Unlike the rest of this module's functions, this function takes an entire
+ course descriptor/overview as a parameter. This is because a few test cases
+ (specifically, {Text|Image|Video}AnnotationModuleTestCase.test_student_view)
+ create scenarios where course.display_name is not None but course.location
+ is None, which causes calling course.url_name to fail. So, although we'd
+ like to just pass course.display_name and course.url_name as arguments to
+ this function, we can't do so without breaking those tests.
+
+ Note: This method no longer escapes as it once did, so the caller must
+ ensure it is properly escaped where necessary.
+
+ Arguments:
+ block (XModuleMixin|CourseOverview|BlockStructureBlockData):
+ Block that is being accessed
+ """
+ return (
+ block.display_name if block.display_name is not None
+ else url_name_for_block(block).replace('_', ' ')
+ )
+
+
+def display_name_with_default_escaped(block):
+ """
+ DEPRECATED: use display_name_with_default
+
+ Calculates the display name for a block with some HTML escaping.
+ This follows the same logic as display_name_with_default, with
+ the addition of the escaping.
+
+ Here is an example of how to move away from this method in Mako html:
+ Before:
+ ${course.display_name_with_default_escaped}
+
+ After:
+ ${course.display_name_with_default | h}
+ If the context is Javascript in Mako, you'll need to follow other best practices.
+
+ Note: Switch to display_name_with_default, and ensure the caller
+ properly escapes where necessary.
+
+ Note: This newly introduced method should not be used. It was only
+ introduced to enable a quick search/replace and the ability to slowly
+ migrate and test switching to display_name_with_default, which is no
+ longer escaped.
+
+ Arguments:
+ block (XModuleMixin|CourseOverview|BlockStructureBlockData):
+ Block that is being accessed
+ """
+ # This escaping is incomplete. However, rather than switching this to use
+ # markupsafe.escape() and fixing issues, better to put that energy toward
+ # migrating away from this method altogether.
+ return display_name_with_default(block).replace('<', '<').replace('>', '>')
diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py
index 8b5ae95ea0..c98663e91c 100644
--- a/common/lib/xmodule/xmodule/contentstore/content.py
+++ b/common/lib/xmodule/xmodule/contentstore/content.py
@@ -5,16 +5,16 @@ from xmodule.assetstore.assetmgr import AssetManager
XASSET_LOCATION_TAG = 'c4x'
XASSET_SRCREF_PREFIX = 'xasset:'
-
XASSET_THUMBNAIL_TAIL_NAME = '.jpg'
-
STREAM_DATA_CHUNK_SIZE = 1024
+VERSIONED_ASSETS_PREFIX = '/assets/courseware'
+VERSIONED_ASSETS_PATTERN = r'/assets/courseware/([a-f0-9]{32})'
import os
import logging
import StringIO
from urlparse import urlparse, urlunparse, parse_qsl
-from urllib import urlencode
+from urllib import urlencode, quote_plus
from opaque_keys.edx.locator import AssetLocator
from opaque_keys.edx.keys import CourseKey, AssetKey
@@ -26,7 +26,7 @@ from PIL import Image
class StaticContent(object):
def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None,
- length=None, locked=False):
+ length=None, locked=False, content_digest=None):
self.location = loc
self.name = name # a display string which can be edited, and thus not part of the location which needs to be fixed
self.content_type = content_type
@@ -38,6 +38,7 @@ class StaticContent(object):
# cycles
self.import_path = import_path
self.locked = locked
+ self.content_digest = content_digest
@property
def is_thumbnail(self):
@@ -145,6 +146,40 @@ class StaticContent(object):
# try stripping off the leading slash and try again
return AssetKey.from_string(path[1:])
+ @staticmethod
+ def is_versioned_asset_path(path):
+ """Determines whether the given asset path is versioned."""
+ return path.startswith(VERSIONED_ASSETS_PREFIX)
+
+ @staticmethod
+ def parse_versioned_asset_path(path):
+ """
+ Examines an asset path and breaks it apart if it is versioned,
+ returning both the asset digest and the unversioned asset path,
+ which will normally be an AssetKey.
+ """
+ asset_digest = None
+ asset_path = path
+ if StaticContent.is_versioned_asset_path(asset_path):
+ result = re.match(VERSIONED_ASSETS_PATTERN, asset_path)
+ if result is not None:
+ asset_digest = result.groups()[0]
+ asset_path = re.sub(VERSIONED_ASSETS_PATTERN, '', asset_path)
+
+ return (asset_digest, asset_path)
+
+ @staticmethod
+ def add_version_to_asset_path(path, version):
+ """
+ Adds a prefix to an asset path indicating the asset's version.
+ """
+
+ # Don't version an already-versioned path.
+ if StaticContent.is_versioned_asset_path(path):
+ return path
+
+ return VERSIONED_ASSETS_PREFIX + '/' + version + path
+
@staticmethod
def get_asset_key_from_path(course_key, path):
"""
@@ -172,7 +207,16 @@ class StaticContent(object):
return StaticContent.compute_location(course_key, path)
@staticmethod
- def get_canonicalized_asset_path(course_key, path, base_url, excluded_exts):
+ def is_excluded_asset_type(path, excluded_exts):
+ """
+ Check if this is an allowed file extension to serve.
+
+ Some files aren't served through the CDN in order to avoid same-origin policy/CORS-related issues.
+ """
+ return any(path.lower().endswith(excluded_ext.lower()) for excluded_ext in excluded_exts)
+
+ @staticmethod
+ def get_canonicalized_asset_path(course_key, path, base_url, excluded_exts, encode=True):
"""
Returns a fully-qualified path to a piece of static content.
@@ -188,25 +232,27 @@ class StaticContent(object):
"""
# Break down the input path.
- _, _, relative_path, params, query_string, fragment = urlparse(path)
+ _, _, relative_path, params, query_string, _ = urlparse(path)
# Convert our path to an asset key if it isn't one already.
asset_key = StaticContent.get_asset_key_from_path(course_key, relative_path)
# Check the status of the asset to see if this can be served via CDN aka publicly.
serve_from_cdn = False
+ content_digest = None
try:
content = AssetManager.find(asset_key, as_stream=True)
- is_locked = getattr(content, "locked", True)
- serve_from_cdn = not is_locked
+ serve_from_cdn = not getattr(content, "locked", True)
+ content_digest = getattr(content, "content_digest", None)
except (ItemNotFoundError, NotFoundError):
# If we can't find the item, just treat it as if it's locked.
serve_from_cdn = False
- # See if this is an allowed file extension to serve. Some files aren't served through the
- # CDN in order to avoid same-origin policy/CORS-related issues.
- if any(relative_path.lower().endswith(excluded_ext.lower()) for excluded_ext in excluded_exts):
+ # Do a generic check to see if anything about this asset disqualifies it from being CDN'd.
+ is_excluded = False
+ if StaticContent.is_excluded_asset_type(relative_path, excluded_exts):
serve_from_cdn = False
+ is_excluded = True
# Update any query parameter values that have asset paths in them. This is for assets that
# require their own after-the-fact values, like a Flash file that needs the path of a config
@@ -215,15 +261,29 @@ class StaticContent(object):
updated_query_params = []
for query_name, query_val in query_params:
if query_val.startswith("/static/"):
- new_val = StaticContent.get_canonicalized_asset_path(course_key, query_val, base_url, excluded_exts)
+ new_val = StaticContent.get_canonicalized_asset_path(
+ course_key, query_val, base_url, excluded_exts, encode=False)
updated_query_params.append((query_name, new_val))
else:
- updated_query_params.append((query_name, query_val))
+ # Make sure we're encoding Unicode strings down to their byte string
+ # representation so that `urlencode` can handle it.
+ updated_query_params.append((query_name, query_val.encode('utf-8')))
serialized_asset_key = StaticContent.serialize_asset_key_with_slash(asset_key)
base_url = base_url if serve_from_cdn else ''
+ asset_path = serialized_asset_key
- return urlunparse((None, base_url, serialized_asset_key, params, urlencode(updated_query_params), fragment))
+ # If the content has a digest (i.e. md5sum) value specified, create a versioned path to the asset using it.
+ if not is_excluded and content_digest:
+ asset_path = StaticContent.add_version_to_asset_path(serialized_asset_key, content_digest)
+
+ # Only encode this if told to. Important so that we don't double encode
+ # when working with paths that are in query parameters.
+ asset_path = asset_path.encode('utf-8')
+ if encode:
+ asset_path = quote_plus(asset_path, '/:+@')
+
+ return urlunparse((None, base_url.encode('utf-8'), asset_path, params, urlencode(updated_query_params), None))
def stream_data(self):
yield self._data
@@ -242,10 +302,10 @@ class StaticContent(object):
class StaticContentStream(StaticContent):
def __init__(self, loc, name, content_type, stream, last_modified_at=None, thumbnail_location=None, import_path=None,
- length=None, locked=False):
+ length=None, locked=False, content_digest=None):
super(StaticContentStream, self).__init__(loc, name, content_type, None, last_modified_at=last_modified_at,
thumbnail_location=thumbnail_location, import_path=import_path,
- length=length, locked=locked)
+ length=length, locked=locked, content_digest=content_digest)
self._stream = stream
def stream_data(self):
@@ -277,7 +337,8 @@ class StaticContentStream(StaticContent):
self._stream.seek(0)
content = StaticContent(self.location, self.name, self.content_type, self._stream.read(),
last_modified_at=self.last_modified_at, thumbnail_location=self.thumbnail_location,
- import_path=self.import_path, length=self.length, locked=self.locked)
+ import_path=self.import_path, length=self.length, locked=self.locked,
+ content_digest=self.content_digest)
return content
diff --git a/common/lib/xmodule/xmodule/contentstore/mongo.py b/common/lib/xmodule/xmodule/contentstore/mongo.py
index 8fc505e121..1d1f2a0314 100644
--- a/common/lib/xmodule/xmodule/contentstore/mongo.py
+++ b/common/lib/xmodule/xmodule/contentstore/mongo.py
@@ -128,7 +128,8 @@ class MongoContentStore(ContentStore):
location, fp.displayname, fp.content_type, fp, last_modified_at=fp.uploadDate,
thumbnail_location=thumbnail_location,
import_path=getattr(fp, 'import_path', None),
- length=fp.length, locked=getattr(fp, 'locked', False)
+ length=fp.length, locked=getattr(fp, 'locked', False),
+ content_digest=getattr(fp, 'md5', None),
)
else:
with self.fs.get(content_id) as fp:
@@ -142,7 +143,8 @@ class MongoContentStore(ContentStore):
location, fp.displayname, fp.content_type, fp.read(), last_modified_at=fp.uploadDate,
thumbnail_location=thumbnail_location,
import_path=getattr(fp, 'import_path', None),
- length=fp.length, locked=getattr(fp, 'locked', False)
+ length=fp.length, locked=getattr(fp, 'locked', False),
+ content_digest=getattr(fp, 'md5', None),
)
except NoFile:
if throw_on_not_found:
diff --git a/common/lib/xmodule/xmodule/course_metadata_utils.py b/common/lib/xmodule/xmodule/course_metadata_utils.py
index 0a6a3ba73c..0586d10274 100644
--- a/common/lib/xmodule/xmodule/course_metadata_utils.py
+++ b/common/lib/xmodule/xmodule/course_metadata_utils.py
@@ -32,78 +32,6 @@ def clean_course_key(course_key, padding_char):
)
-def url_name_for_course_location(location):
- """
- Given a course's usage locator, returns the course's URL name.
-
- Arguments:
- location (BlockUsageLocator): The course's usage locator.
- """
- return location.name
-
-
-def display_name_with_default(course):
- """
- Calculates the display name for a course.
-
- Default to the display_name if it isn't None, else fall back to creating
- a name based on the URL.
-
- Unlike the rest of this module's functions, this function takes an entire
- course descriptor/overview as a parameter. This is because a few test cases
- (specifically, {Text|Image|Video}AnnotationModuleTestCase.test_student_view)
- create scenarios where course.display_name is not None but course.location
- is None, which causes calling course.url_name to fail. So, although we'd
- like to just pass course.display_name and course.url_name as arguments to
- this function, we can't do so without breaking those tests.
-
- Note: This method no longer escapes as it once did, so the caller must
- ensure it is properly escaped where necessary.
-
- Arguments:
- course (CourseDescriptor|CourseOverview): descriptor or overview of
- said course.
- """
- return (
- course.display_name if course.display_name is not None
- else course.url_name.replace('_', ' ')
- )
-
-
-def display_name_with_default_escaped(course):
- """
- DEPRECATED: use display_name_with_default
-
- Calculates the display name for a course with some HTML escaping.
- This follows the same logic as display_name_with_default, with
- the addition of the escaping.
-
- Here is an example of how to move away from this method in Mako html:
- Before:
- ${course.display_name_with_default_escaped}
-
- After:
- ${course.display_name_with_default | h}
- If the context is Javascript in Mako, you'll need to follow other best practices.
-
- Note: Switch to display_name_with_default, and ensure the caller
- properly escapes where necessary.
-
- Note: This newly introduced method should not be used. It was only
- introduced to enable a quick search/replace and the ability to slowly
- migrate and test switching to display_name_with_default, which is no
- longer escaped.
-
- Arguments:
- course (CourseDescriptor|CourseOverview): descriptor or overview of
- said course.
- """
- # This escaping is incomplete. However, rather than switching this to use
- # markupsafe.escape() and fixing issues, better to put that energy toward
- # migrating away from this method altogether.
- return course.display_name_with_default.replace('<', '<').replace('>', '>')
-
-
def number_for_course_location(location):
"""
Given a course's block usage locator, returns the course's number.
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index 361523dcfd..bd9ff594ab 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -11,12 +11,10 @@ from django.utils.timezone import UTC
from lazy import lazy
from lxml import etree
from path import Path as path
-from xblock.core import XBlock
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
from xmodule import course_metadata_utils
from xmodule.course_metadata_utils import DEFAULT_START_DATE
-from xmodule.exceptions import UndefinedContext
from xmodule.graders import grader_from_conf
from xmodule.mixin import LicenseMixin
from xmodule.seq_module import SequenceDescriptor, SequenceModule
@@ -1183,83 +1181,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
"""
return course_metadata_utils.sorting_score(self.start, self.advertised_start, self.announcement)
- @lazy
- def grading_context(self):
- """
- This returns a dictionary with keys necessary for quickly grading
- a student. They are used by grades.grade()
-
- The grading context has two keys:
- graded_sections - This contains the sections that are graded, as
- well as all possible children modules that can affect the
- grading. This allows some sections to be skipped if the student
- hasn't seen any part of it.
-
- The format is a dictionary keyed by section-type. The values are
- arrays of dictionaries containing
- "section_descriptor" : The section descriptor
- "xmoduledescriptors" : An array of xmoduledescriptors that
- could possibly be in the section, for any student
-
- all_descriptors - This contains a list of all xmodules that can
- effect grading a student. This is used to efficiently fetch
- all the xmodule state for a FieldDataCache without walking
- the descriptor tree again.
-
-
- """
- # If this descriptor has been bound to a student, return the corresponding
- # XModule. If not, just use the descriptor itself
- try:
- module = getattr(self, '_xmodule', None)
- if not module:
- module = self
- except UndefinedContext:
- module = self
-
- def possibly_scored(usage_key):
- """Can this XBlock type can have a score or children?"""
- return usage_key.block_type in self.block_types_affecting_grading
-
- all_descriptors = []
- graded_sections = {}
-
- def yield_descriptor_descendents(module_descriptor):
- for child in module_descriptor.get_children(usage_key_filter=possibly_scored):
- yield child
- for module_descriptor in yield_descriptor_descendents(child):
- yield module_descriptor
-
- for chapter in self.get_children():
- for section in chapter.get_children():
- if section.graded:
- xmoduledescriptors = list(yield_descriptor_descendents(section))
- xmoduledescriptors.append(section)
-
- # The xmoduledescriptors included here are only the ones that have scores.
- section_description = {
- 'section_descriptor': section,
- 'xmoduledescriptors': [child for child in xmoduledescriptors if child.has_score]
- }
-
- section_format = section.format if section.format is not None else ''
- graded_sections[section_format] = graded_sections.get(section_format, []) + [section_description]
-
- all_descriptors.extend(xmoduledescriptors)
- all_descriptors.append(section)
-
- return {'graded_sections': graded_sections,
- 'all_descriptors': all_descriptors, }
-
- @lazy
- def block_types_affecting_grading(self):
- """Return all block types that could impact grading (i.e. scored, or having children)."""
- return frozenset(
- cat for (cat, xblock_class) in XBlock.load_classes() if (
- getattr(xblock_class, 'has_score', False) or getattr(xblock_class, 'has_children', False)
- )
- )
-
@staticmethod
def make_id(org, course, url_name):
return '/'.join([org, course, url_name])
diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss
index bd478ab0a2..060842d788 100644
--- a/common/lib/xmodule/xmodule/css/video/display.scss
+++ b/common/lib/xmodule/xmodule/css/video/display.scss
@@ -535,6 +535,8 @@
.speed-option,
.control-lang {
+ @include border-left($baseline/10 solid rgb(14, 166, 236));
+ font-weight: $font-bold;
color: rgb(14, 166, 236); // UXPL primary accent
}
}
diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py
index 64d2d76923..cb47fa2dbb 100644
--- a/common/lib/xmodule/xmodule/graders.py
+++ b/common/lib/xmodule/xmodule/graders.py
@@ -173,7 +173,7 @@ class WeightedSubsectionsGrader(CourseGrader):
All items in section_breakdown for each subgrader will be combined. A grade_breakdown will be
composed using the score from each grader.
- Note that the sum of the weights is not take into consideration. If the weights add up to
+ Note that the sum of the weights is not taken into consideration. If the weights add up to
a value > 1, the student may end up with a percent > 100%. This allows for sections that
are extra credit.
"""
diff --git a/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html
index d4f5401785..c941c535a6 100644
--- a/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html
+++ b/common/lib/xmodule/xmodule/js/fixtures/video_yt_multiple.html
@@ -55,8 +55,9 @@
-
-
+
+
+
@@ -108,7 +109,9 @@
-
+
+
+
diff --git a/common/lib/xmodule/xmodule/js/karma_xmodule.conf.js b/common/lib/xmodule/xmodule/js/karma_xmodule.conf.js
index 5ee38962a7..3f72dcb7b0 100644
--- a/common/lib/xmodule/xmodule/js/karma_xmodule.conf.js
+++ b/common/lib/xmodule/xmodule/js/karma_xmodule.conf.js
@@ -20,12 +20,10 @@ var options = {
libraryFilesToInclude: [
{pattern: 'common_static/js/vendor/requirejs/require.js', included: true},
{pattern: 'RequireJS-namespace-undefine.js', included: true},
- {pattern: 'spec/main_requirejs.js', included: true},
{pattern: 'common_static/coffee/src/ajax_prefix.js', included: true},
{pattern: 'common_static/common/js/vendor/underscore.js', included: true},
{pattern: 'common_static/common/js/vendor/backbone.js', included: true},
- {pattern: 'common_static/edx-ui-toolkit/js/utils/global-loader.js', included: true},
{pattern: 'common_static/js/vendor/CodeMirror/codemirror.js', included: true},
{pattern: 'common_static/js/vendor/draggabilly.js'},
{pattern: 'common_static/common/js/vendor/jquery.js', included: true},
@@ -50,11 +48,14 @@ var options = {
{pattern: 'common_static/js/vendor/jasmine-imagediff.js', included: true},
{pattern: 'common_static/common/js/spec_helpers/jasmine-waituntil.js', included: true},
{pattern: 'common_static/common/js/spec_helpers/jasmine-extensions.js', included: true},
- {pattern: 'common_static/js/vendor/sinon-1.17.0.js', included: true}
+ {pattern: 'common_static/js/vendor/sinon-1.17.0.js', included: true},
+
+ {pattern: 'spec/main_requirejs.js', included: true},
],
libraryFiles: [
- {pattern: 'common_static/edx-pattern-library/js/**/*.js'}
+ {pattern: 'common_static/edx-pattern-library/js/**/*.js'},
+ {pattern: 'common_static/edx-ui-toolkit/js/**/*.js'}
],
// Make sure the patterns in sourceFiles and specFiles do not match the same file.
diff --git a/common/lib/xmodule/xmodule/js/spec/main_requirejs.js b/common/lib/xmodule/xmodule/js/spec/main_requirejs.js
index a57497f8c8..d4692469e5 100644
--- a/common/lib/xmodule/xmodule/js/spec/main_requirejs.js
+++ b/common/lib/xmodule/xmodule/js/spec/main_requirejs.js
@@ -1,4 +1,36 @@
-(function(requirejs) {
+(function(requirejs, define) {
+ 'use strict';
+ // We do not wish to bundle common libraries (that may also be used by non-RequireJS code on the page
+ // into the optimized files. Therefore load these libraries through script tags and explicitly define them.
+ // Note that when the optimizer executes this code, window will not be defined.
+ if (window) {
+ var defineDependency = function (globalName, name, noShim) {
+ var getGlobalValue = function(name) {
+ var globalNamePath = name.split('.'),
+ result = window,
+ i;
+ for (i = 0; i < globalNamePath.length; i++) {
+ result = result[globalNamePath[i]];
+ }
+ return result;
+ },
+ globalValue = getGlobalValue(globalName);
+ if (globalValue) {
+ if (noShim) {
+ define(name, {});
+ }
+ else {
+ define(name, [], function() { return globalValue; });
+ }
+ }
+ else {
+ console.error("Expected library to be included on page, but not found on window object: " + name);
+ }
+ };
+ defineDependency("jQuery", "jquery");
+ defineDependency("jQuery", "jquery-migrate");
+ defineDependency("_", "underscore");
+ }
requirejs.config({
baseUrl: '/base/',
paths: {
@@ -6,7 +38,8 @@
"modernizr": "common_static/edx-pattern-library/js/modernizr-custom",
"afontgarde": "common_static/edx-pattern-library/js/afontgarde",
"edxicons": "common_static/edx-pattern-library/js/edx-icons",
- "draggabilly": "common_static/js/vendor/draggabilly"
+ "draggabilly": "common_static/js/vendor/draggabilly",
+ 'edx-ui-toolkit': 'common_static/edx-ui-toolkit'
},
"moment": {
exports: "moment"
@@ -18,5 +51,4 @@
exports: "AFontGarde"
}
});
-
-}).call(this, RequireJS.requirejs);
+}).call(this, RequireJS.requirejs, RequireJS.define);
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
index b8cf3065b1..5d85be2551 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js
@@ -266,6 +266,7 @@
expect($('.closed-captions')).toHaveAttrs({
'lang': 'de'
});
+ expect(link).toHaveAttr('aria-pressed', 'true');
});
it('when clicking on link with current language', function () {
@@ -284,6 +285,7 @@
expect(state.storage.setItem)
.not.toHaveBeenCalledWith('language', 'en');
expect($('.langs-list li.is-active').length).toBe(1);
+ expect(link).toHaveAttr('aria-pressed', 'true');
});
it('open the language toggle on hover', function () {
@@ -413,7 +415,7 @@
});
it('show explanation message', function () {
- expect($('.subtitles-menu li')).toHaveText(
+ expect($('.subtitles .subtitles-menu li')).toHaveText(
'Transcript will be displayed when you start playing the video.'
);
});
diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js
index 2a8b5e8b67..f73b1f3c59 100644
--- a/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js
+++ b/common/lib/xmodule/xmodule/js/spec/video/video_speed_control_spec.js
@@ -203,16 +203,18 @@
describe('onSpeedChange', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
- $('li[data-speed="1.0"]').addClass('is-active');
+ $('li[data-speed="1.0"]').addClass('is-active').attr('aria-pressed', 'true');
state.videoSpeedControl.setSpeed(0.75);
});
it('set the new speed as active', function () {
- expect($('.video-speeds li[data-speed="1.0"]'))
- .not.toHaveClass('is-active');
- expect($('.video-speeds li[data-speed="0.75"]'))
- .toHaveClass('is-active');
- expect($('.speeds .value')).toHaveHtml('0.75x');
+ expect($('li[data-speed="1.0"]')).not.toHaveClass('is-active');
+ expect($('li[data-speed="1.0"] .speed-option').attr('aria-pressed')).not.toEqual('true');
+
+ expect($('li[data-speed="0.75"]')).toHaveClass('is-active');
+ expect($('li[data-speed="0.75"] .speed-option').attr('aria-pressed')).toEqual('true');
+
+ expect($('.speeds .speed-button .value')).toHaveHtml('0.75x');
});
});
diff --git a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
index 986d9ea7ff..ace6f39114 100644
--- a/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
+++ b/common/lib/xmodule/xmodule/js/src/video/08_video_speed_control.js
@@ -1,9 +1,10 @@
(function (requirejs, require, define) {
"use strict";
define(
-'video/08_video_speed_control.js',
-['video/00_iterator.js'],
-function (Iterator) {
+'video/08_video_speed_control.js', [
+ 'video/00_iterator.js',
+ 'edx-ui-toolkit/js/utils/html-utils'
+], function (Iterator, HtmlUtils) {
/**
* Video speed control module.
* @exports video/08_video_speed_control.js
@@ -95,23 +96,38 @@ function (Iterator) {
* Creates any necessary DOM elements, attach them, and set their,
* initial configuration.
* @param {array} speeds List of speeds available for the player.
+ * @param {string} currentSpeed The current speed set to the player.
*/
- render: function (speeds) {
+ render: function (speeds, currentSpeed) {
var speedsContainer = this.speedsContainer,
reversedSpeeds = speeds.concat().reverse(),
speedsList = $.map(reversedSpeeds, function (speed) {
- return [
- '',
- '',
- ''
- ].join('');
+ return HtmlUtils.interpolateHtml(
+ HtmlUtils.HTML(
+ [
+ '',
+ '',
+ ''
+ ].join('')
+ ),
+ {
+ speed: speed
+ }
+ ).toString();
});
- speedsContainer.html(speedsList.join(''));
+ HtmlUtils.setHtml(
+ speedsContainer,
+ HtmlUtils.HTML(speedsList)
+ );
this.speedLinks = new Iterator(speedsContainer.find('.speed-option'));
- this.state.el.find('.secondary-controls').prepend(this.el);
+ HtmlUtils.prepend(
+ this.state.el.find('.secondary-controls'),
+ HtmlUtils.HTML(this.el)
+ );
+ this.setActiveSpeed(currentSpeed);
},
/**
@@ -216,17 +232,38 @@ function (Iterator) {
if (speed !== this.currentSpeed || forceUpdate) {
this.speedsContainer
.find('li')
- .removeClass('is-active')
- .siblings("li[data-speed='" + speed + "']")
- .addClass('is-active');
+ .siblings("li[data-speed='" + speed + "']");
- this.speedButton.find('.value').html(speed + 'x');
+ this.speedButton.find('.value').text(speed + 'x');
this.currentSpeed = speed;
if (!silent) {
this.el.trigger('speedchange', [speed, this.state.speed]);
}
}
+
+ this.resetActiveSpeed();
+ this.setActiveSpeed(speed);
+ },
+
+ resetActiveSpeed: function() {
+ var speedOptions = this.speedsContainer.find('li');
+
+ $(speedOptions).each(function(index, el) {
+ $(el).removeClass('is-active')
+ .find('.speed-option')
+ .attr('aria-pressed', 'false');
+ });
+ },
+
+ setActiveSpeed: function(speed) {
+ var speedOption = this.speedsContainer.find('li[data-speed="' + speed + '"]');
+
+ speedOption.addClass('is-active')
+ .find('.speed-option')
+ .attr('aria-pressed', 'true');
+
+ this.speedButton.attr('title', gettext('Video speed: ') + speed + 'x');
},
/**
@@ -244,10 +281,13 @@ function (Iterator) {
* @param {jquery Event} event
*/
clickLinkHandler: function (event) {
- var speed = $(event.currentTarget).parent().data('speed');
-
- this.closeMenu();
+ var el = $(event.currentTarget).parent(),
+ speed = $(el).data('speed');
+
+ this.resetActiveSpeed();
+ this.setActiveSpeed(speed);
this.state.videoCommands.execute('speed', speed);
+ this.closeMenu(true);
return false;
},
diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
index b99a1b1128..b251d128bb 100644
--- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
+++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js
@@ -5,11 +5,12 @@
define('video/09_video_caption.js',[
'video/00_sjson.js',
'video/00_async_process.js',
+ 'edx-ui-toolkit/js/utils/html-utils',
'draggabilly',
'modernizr',
'afontgarde',
'edxicons'
- ], function (Sjson, AsyncProcess, Draggabilly) {
+ ], function (Sjson, AsyncProcess, HtmlUtils, Draggabilly) {
/**
* @desc VideoCaption module exports a function.
@@ -80,47 +81,60 @@
renderElements: function () {
var languages = this.state.config.transcriptLanguages;
- var langTemplate = [
- '',
- '',
- '',
- '',
- '
'
- ].join('');
+ var langHtml = HtmlUtils.interpolateHtml(
+ HtmlUtils.HTML(
+ [
+ '',
+ '',
+ '',
+ '',
+ '
'
+ ].join(''),
+ {
+ langTitle: gettext('Open language menu')
+ }
+ )
+
+ );
- var template = [
- '',
- '
',
- '',
- '
'
- ].join('');
+ var subtitlesHtml = HtmlUtils.interpolateHtml(
+ HtmlUtils.HTML(
+ [
+ '',
+ '
',
+ '',
+ ''
+ ].join('')),
+ {
+ courseId: this.state.id,
+ courseLang: this.state.lang
+ }
+ );
this.loaded = false;
- this.subtitlesEl = $(template);
+ this.subtitlesEl = $(HtmlUtils.ensureHtml(subtitlesHtml).toString());
this.subtitlesMenuEl = this.subtitlesEl.find('.subtitles-menu');
- this.container = $(langTemplate);
+ this.container = $(HtmlUtils.ensureHtml(langHtml).toString());
this.captionControlEl = this.container.find('.toggle-captions');
this.captionDisplayEl = this.state.el.find('.closed-captions');
this.transcriptControlEl = this.container.find('.toggle-transcript');
@@ -542,15 +556,26 @@
}
} else {
if (state.isTouch) {
- self.subtitlesEl.find('.subtitles-menu')
- .text(gettext('Transcript will be displayed when you start playing the video.')) // jshint ignore: line
- .wrapInner('');
+ HtmlUtils.setHtml(
+ self.subtitlesEl.find('.subtitles-menu'),
+ HtmlUtils.joinHtml(
+ HtmlUtils.HTML(''),
+ gettext('Transcript will be displayed when you start playing the video.'),
+ HtmlUtils.HTML('')
+ )
+ );
} else {
self.renderCaption(start, captions);
}
self.hideCaptions(state.hide_captions, false);
- self.state.el.find('.video-wrapper').after(self.subtitlesEl);
- self.state.el.find('.secondary-controls').append(self.container);
+ HtmlUtils.append(
+ self.state.el.find('.video-wrapper').parent(),
+ HtmlUtils.HTML(self.subtitlesEl)
+ );
+ HtmlUtils.append(
+ self.state.el.find('.secondary-controls'),
+ HtmlUtils.HTML(self.container)
+ );
self.bindHandlers();
}
@@ -630,9 +655,11 @@
onResize: function () {
this.subtitlesEl
.find('.spacing').first()
- .height(this.topSpacingHeight()).end()
+ .height(this.topSpacingHeight());
+
+ this.subtitlesEl
.find('.spacing').last()
- .height(this.bottomSpacingHeight());
+ .height(this.bottomSpacingHeight());
this.scrollCaption();
this.setSubtitlesHeight();
@@ -649,8 +676,9 @@
renderLanguageMenu: function (languages) {
var self = this,
state = this.state,
- menu = $('Some images in this post have been omitted
'
- else
- expectedHtml = expectedHtml + '' + expectedText + ''
-
- view = makeView(makeThread(threadData))
- view.render()
- expect(view.$el.find(".post-body").html()).toEqual(expectedHtml)
-
- checkBody = (truncated, view, threadData) ->
- view.render()
- if not truncated
- expect(view.model.get("body")).toEqual(view.model.get("abbreviatedBody"))
- expect(view.$el.find(".post-body").html()).toEqual(threadData.body)
- else
- expect(view.model.get("body")).not.toEqual(view.model.get("abbreviatedBody"))
- expect(view.$el.find(".post-body").html()).not.toEqual(threadData.body)
- outputHtmlStripped = view.$el.find(".post-body").html().replace(/(<([^>]+)>)/ig,"");
- outputHtmlStripped = outputHtmlStripped.replace("Some images in this post have been omitted","")
- outputHtmlStripped = outputHtmlStripped.replace("image omitted","")
- inputHtmlStripped = threadData.body.replace(/(<([^>]+)>)/ig,"");
- expectedOutput = inputHtmlStripped.substring(0, 139)+ '…'
- expect(outputHtmlStripped).toEqual(expectedOutput)
- expect(view.$el.find(".post-body").html().indexOf("…")).toBeGreaterThan(0)
-
- describe "Body markdown should be correct", ->
-
- it "untruncated text without markdown body", ->
- @threadData.body = "Test body"
- view = makeView(makeThread(@threadData))
- checkBody(false, view, @threadData)
-
- it "truncated text without markdown body", ->
- @threadData.body = new Array(100).join("test ")
- view = makeView(makeThread(@threadData))
- checkBody(true, view, @threadData)
-
- it "untruncated text with markdown body", ->
- @threadData.body = '' + @imageTag + 'Google top search engine
'
- view = makeView(makeThread(@threadData))
- checkBody(false, view, @threadData)
-
- it "truncated text with markdown body", ->
- testText = new Array(100).join("test ")
- @threadData.body = '' + @imageTag + @imageTag + '' + testText + '
'
- view = makeView(makeThread(@threadData))
- checkBody(true, view, @threadData)
-
- for numImages in [1, 2, 10]
- for truncatedText in [true, false]
- it "body with #{numImages} images and #{if truncatedText then "truncated" else "untruncated"} text", ->
- checkPostWithImages(numImages, truncatedText, @threadData, @imageTag)
-
- it "check the thread retrieve url", ->
- thread = makeThread(@threadData)
- expect(thread.urlFor('retrieve')).toBe('/courses/edX/999/test/discussion/forum/dummy_discussion/threads/1')
diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_show_view_spec.coffee b/common/static/coffee/spec/discussion/view/discussion_thread_show_view_spec.coffee
deleted file mode 100644
index 34036d847b..0000000000
--- a/common/static/coffee/spec/discussion/view/discussion_thread_show_view_spec.coffee
+++ /dev/null
@@ -1,159 +0,0 @@
-describe "DiscussionThreadShowView", ->
- beforeEach ->
- DiscussionSpecHelper.setUpGlobals()
- DiscussionSpecHelper.setUnderscoreFixtures()
-
- @user = DiscussionUtil.getUser()
- @threadData = {
- id: "dummy",
- user_id: @user.id,
- username: @user.get('username'),
- course_id: $$course_id,
- title: "dummy title",
- body: "this is a thread",
- created_at: "2013-04-03T20:08:39Z",
- abuse_flaggers: [],
- votes: {up_count: 42},
- thread_type: "discussion",
- closed: false,
- pinned: false,
- type: "thread" # TODO - silly that this needs to be explicitly set
- }
- @thread = new Thread(@threadData)
- @view = new DiscussionThreadShowView({ model: @thread })
- @view.setElement($("#fixture-element"))
- spyOn(@view, "convertMath")
-
- describe "voting", ->
-
- it "renders the vote state correctly", ->
- DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
-
- it "votes correctly via click", ->
- DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("click"))
-
- it "votes correctly via spacebar", ->
- DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
-
- it "unvotes correctly via click", ->
- DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("click"))
-
- it "unvotes correctly via spacebar", ->
- DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
-
- describe "pinning", ->
-
- expectPinnedRendered = (view, model) ->
- pinned = model.get('pinned')
- button = view.$el.find(".action-pin")
- expect(button.hasClass("is-checked")).toBe(pinned)
- expect(button.attr("aria-checked")).toEqual(pinned.toString())
-
- it "renders the pinned state correctly", ->
- @view.render()
- expectPinnedRendered(@view, @thread)
- @thread.set('pinned', false)
- @view.render()
- expectPinnedRendered(@view, @thread)
- @thread.set('pinned', true)
- @view.render()
- expectPinnedRendered(@view, @thread)
-
- it "exposes the pinning control only to authorized users", ->
- @thread.updateInfo({ability: {can_openclose: false}})
- @view.render()
- expect(@view.$el.find(".action-pin").closest(".is-hidden")).toExist()
- @thread.updateInfo({ability: {can_openclose: true}})
- @view.render()
- expect(@view.$el.find(".action-pin").closest(".is-hidden")).not.toExist()
-
- it "handles events correctly", ->
- @view.render()
- DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".action-pin")
-
- describe "labels", ->
-
- expectOneElement = (view, selector, visible=true) =>
- view.render()
- elements = view.$el.find(selector)
- expect(elements.length).toEqual(1)
- if visible
- expect(elements).not.toHaveClass("is-hidden")
- else
- expect(elements).toHaveClass("is-hidden")
-
- it 'displays the closed label when appropriate', ->
- expectOneElement(@view, '.post-label-closed', false)
- @thread.set('closed', true)
- expectOneElement(@view, '.post-label-closed')
-
- it 'displays the pinned label when appropriate', ->
- expectOneElement(@view, '.post-label-pinned', false)
- @thread.set('pinned', true)
- expectOneElement(@view, '.post-label-pinned')
-
- it 'displays the reported label when appropriate for a non-staff user', ->
- expectOneElement(@view, '.post-label-reported', false)
- # flagged by current user - should be labelled
- @thread.set('abuse_flaggers', [DiscussionUtil.getUser().id])
- expectOneElement(@view, '.post-label-reported')
- # flagged by some other user but not the current one - should not be labelled
- @thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
- expectOneElement(@view, '.post-label-reported', false)
-
- it 'displays the reported label when appropriate for a flag moderator', ->
- DiscussionSpecHelper.makeModerator()
- expectOneElement(@view, '.post-label-reported', false)
- # flagged by current user - should be labelled
- @thread.set('abuse_flaggers', [DiscussionUtil.getUser().id])
- expectOneElement(@view, '.post-label-reported')
- # flagged by some other user but not the current one - should still be labelled
- @thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
- expectOneElement(@view, '.post-label-reported')
-
- describe "author display", ->
-
- beforeEach ->
- @thread.set('user_url', 'test_user_url')
-
- checkUserLink = (element, is_ta, is_staff) ->
- expect(element.find('a.username').length).toEqual(1)
- expect(element.find('a.username').text()).toEqual('test_user')
- expect(element.find('a.username').attr('href')).toEqual('test_user_url')
- expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0)
- expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0)
-
- it "renders correctly for a student-authored thread", ->
- $el = $('#fixture-element').html(@view.getAuthorDisplay())
- checkUserLink($el, false, false)
-
- it "renders correctly for a community TA-authored thread", ->
- @thread.set('community_ta_authored', true)
- $el = $('#fixture-element').html(@view.getAuthorDisplay())
- checkUserLink($el, true, false)
-
- it "renders correctly for a staff-authored thread", ->
- @thread.set('staff_authored', true)
- $el = $('#fixture-element').html(@view.getAuthorDisplay())
- checkUserLink($el, false, true)
-
- it "renders correctly for an anonymously-authored thread", ->
- @thread.set('username', null)
- $el = $('#fixture-element').html(@view.getAuthorDisplay())
- expect($el.find('a.username').length).toEqual(0)
- expect($el.text()).toMatch(/^(\s*)anonymous(\s*)$/)
-
- describe "cohorting", ->
- it "renders correctly for an uncohorted thread", ->
- @view.render()
- expect(@view.$('.group-visibility-label').text().trim()).toEqual(
- 'This post is visible to everyone.'
- )
-
- it "renders correctly for a cohorted thread", ->
- @thread.set('group_id', '1')
- @thread.set('group_name', 'Mock Cohort')
- @view.render()
- expect(@view.$('.group-visibility-label').text().trim()).toEqual(
- 'This post is visible only to Mock Cohort.'
- )
diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee b/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee
deleted file mode 100644
index 53904d9b87..0000000000
--- a/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee
+++ /dev/null
@@ -1,410 +0,0 @@
-describe "DiscussionThreadView", ->
- beforeEach ->
- DiscussionSpecHelper.setUpGlobals()
- DiscussionSpecHelper.setUnderscoreFixtures()
-
- jasmine.clock().install()
- @threadData = DiscussionViewSpecHelper.makeThreadWithProps({})
- @thread = new Thread(@threadData)
- @discussion = new Discussion(@thread)
- deferred = $.Deferred();
- spyOn($, "ajax").and.returnValue(deferred);
- # Avoid unnecessary boilerplate
- spyOn(DiscussionThreadShowView.prototype, "convertMath")
- spyOn(DiscussionContentView.prototype, "makeWmdEditor")
- spyOn(DiscussionUtil, "makeWmdEditor")
- spyOn(DiscussionUtil, "setWmdContent")
- spyOn(ThreadResponseShowView.prototype, "convertMath")
-
- afterEach ->
- $.ajax.calls.reset()
- jasmine.clock().uninstall()
-
- renderWithContent = (view, content) ->
- $.ajax.and.callFake((params) =>
- params.success(
- createAjaxResponseJson(content, false),
- 'success'
- )
- {always: ->}
- )
- view.render()
- jasmine.clock().tick(100)
-
- renderWithTestResponses = (view, count, options) ->
- renderWithContent(
- view,
- _.extend(
- {
- resp_total: count,
- children: if count > 0 then (createTestResponseJson(index) for index in [1..count]) else []
- },
- options
- )
- )
-
- createTestResponseJson = (index) ->
- {
- user_id: window.user.id,
- body: "Response " + index,
- id: "id_" + index,
- created_at: "2015-01-01T22:20:28Z"
- }
-
- assertContentVisible = (view, selector, visible) ->
- content = view.$el.find(selector)
- expect(content.length).toBeGreaterThan(0)
- content.each (i, elem) ->
- expect($(elem).is(":visible")).toEqual(visible)
-
- assertExpandedContentVisible = (view, expanded) ->
- expect(view.$el.hasClass("expanded")).toEqual(expanded)
- assertContentVisible(view, ".post-extended-content", expanded)
- assertContentVisible(view, ".forum-thread-expand", not expanded)
- assertContentVisible(view, ".forum-thread-collapse", expanded)
-
- assertResponseCountAndPaginationCorrect = (view, countText, displayCountText, buttonText) ->
- expect(view.$el.find(".response-count").text()).toEqual(countText)
- if displayCountText
- expect(view.$el.find(".response-display-count").text()).toEqual(displayCountText)
- else
- expect(view.$el.find(".response-display-count").length).toEqual(0)
- if buttonText
- expect(view.$el.find(".load-response-button").text()).toEqual(buttonText)
- else
- expect(view.$el.find(".load-response-button").length).toEqual(0)
-
- createAjaxResponseJson = (content, can_act) ->
- {
- content: content,
- annotated_content_info: {
- ability: {
- editable: can_act,
- can_delete: can_act,
- can_reply: can_act,
- can_vote: can_act
- }
- }
- }
-
- postResponse = (view, index) ->
- testResponseJson = createTestResponseJson(index)
- responseText = testResponseJson.body
- spyOn(view, "getWmdContent").and.returnValue(responseText)
- $.ajax.and.callFake((params) =>
- expect(params.type).toEqual("POST")
- expect(params.data.body).toEqual(responseText)
- params.success(
- createAjaxResponseJson(testResponseJson, true),
- 'success'
- )
- {always: ->}
- )
- view.$(".discussion-submit-post").click()
-
- describe "closed and open Threads", ->
-
- createDiscussionThreadView = (originallyClosed, mode) ->
- threadData = DiscussionViewSpecHelper.makeThreadWithProps({closed: originallyClosed})
- thread = new Thread(threadData)
- discussion = new Discussion(thread)
- view = new DiscussionThreadView(
- model: thread
- el: $("#fixture-element")
- mode: mode
- course_settings: DiscussionSpecHelper.makeCourseSettings()
- )
- renderWithTestResponses(view, 1)
- if mode == "inline"
- view.expand()
- spyOn(DiscussionUtil, "updateWithUndo").and.callFake(
- (model, updates, safeAjaxParams, errorMsg) ->
- model.set(updates)
- )
- view
-
- checkCommentForm = (originallyClosed, mode) ->
- view = createDiscussionThreadView(originallyClosed, mode)
- expect(view.$('.comment-form').closest('li').is(":visible")).toBe(not originallyClosed)
- expect(view.$(".discussion-reply-new").is(":visible")).toBe(not originallyClosed)
- view.$(".action-close").click()
- expect(view.$('.comment-form').closest('li').is(":visible")).toBe(originallyClosed)
- expect(view.$(".discussion-reply-new").is(":visible")).toBe(originallyClosed)
-
- checkVoteDisplay = (originallyClosed, mode) ->
- view = createDiscussionThreadView(originallyClosed, mode)
- expect(view.$('.thread-main-wrapper .action-vote').is(":visible")).toBe(not originallyClosed)
- expect(view.$('.thread-main-wrapper .display-vote').is(":visible")).toBe(originallyClosed)
- view.$(".action-close").click()
- expect(view.$('.action-vote').is(":visible")).toBe(originallyClosed)
- expect(view.$('.display-vote').is(":visible")).toBe(not originallyClosed)
-
- _.each(["tab", "inline"], (mode) =>
- it "Test that in #{mode} mode when a closed thread is opened the comment form is displayed", ->
- checkCommentForm(true, mode)
-
- it "Test that in #{mode} mode when a open thread is closed the comment form is hidden", ->
- checkCommentForm(false, mode)
-
- it "Test that in #{mode} mode when a closed thread is opened the vote button is displayed and vote count is hidden", ->
- checkVoteDisplay(true, mode)
-
- it "Test that in #{mode} mode when a open thread is closed the vote button is hidden and vote count is displayed", ->
- checkVoteDisplay(false, mode)
- )
-
- describe "tab mode", ->
- beforeEach ->
- @view = new DiscussionThreadView(
- model: @thread
- el: $("#fixture-element")
- mode: "tab"
- course_settings: DiscussionSpecHelper.makeCourseSettings()
- )
-
- describe "responses", ->
- it "can post a first response", ->
- # Initially render a test post (made by someone else) with zero responses
- renderWithTestResponses(@view, 0)
- postResponse(@view, 1)
- expect(@view.$(".forum-response").length).toBe(1)
- # At this point, there are 2 DiscussionContentViews, the main post and the response.
- # Each an .action-edit button, but only 1 (the response) should be available.
- expect(@view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1)
- expect(@view.$(".response-actions-list").find(".action-edit").parent().not(".is-hidden").length).toBe(1)
-
- it "can post a second response", ->
- # Initially render a test post (made by someone else) with a single response (made by the current learner)
- renderWithTestResponses(@view, 1)
- expect(@view.$(".forum-response").length).toBe(1)
- # Post should not be editable, response should be
- expect(@view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1)
- expect(@view.$(".response-actions-list").find(".action-edit").parent().not(".is-hidden").length).toBe(1)
-
- # Now make a second response. Prior to TNL-3788, a bug would hide the edit button for the first response
- postResponse(@view, 2)
- expect(@view.$(".forum-response").length).toBe(2)
- # Post should not be editable, responses should be
- expect(@view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1)
- expect(@view.$(".response-actions-list").find(".action-edit").parent().not(".is-hidden").length).toBe(2)
-
- describe "response count and pagination", ->
- it "correctly render for a thread with no responses", ->
- renderWithTestResponses(@view, 0)
- assertResponseCountAndPaginationCorrect(@view, "0 responses", null, null)
-
- it "correctly render for a thread with one response", ->
- renderWithTestResponses(@view, 1)
- assertResponseCountAndPaginationCorrect(@view, "1 response", "Showing all responses", null)
-
- it "correctly render for a thread with one additional page", ->
- renderWithTestResponses(@view, 1, {resp_total: 2})
- assertResponseCountAndPaginationCorrect(@view, "2 responses", "Showing first response", "Load all responses")
-
- it "correctly render for a thread with multiple additional pages", ->
- renderWithTestResponses(@view, 2, {resp_total: 111})
- assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 2 responses", "Load next 100 responses")
-
- describe "on clicking the load more button", ->
- beforeEach ->
- renderWithTestResponses(@view, 1, {resp_total: 5})
- assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing first response", "Load all responses")
-
- it "correctly re-render when all threads have loaded", ->
- renderWithTestResponses(@view, 5, {resp_total: 5})
- @view.$el.find(".load-response-button").click()
- assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing all responses", null)
-
- it "correctly re-render when one page remains", ->
- renderWithTestResponses(@view, 3, {resp_total: 42})
- @view.$el.find(".load-response-button").click()
- assertResponseCountAndPaginationCorrect(@view, "42 responses", "Showing first 3 responses", "Load all responses")
-
- it "correctly re-render when multiple pages remain", ->
- renderWithTestResponses(@view, 3, {resp_total: 111})
- @view.$el.find(".load-response-button").click()
- assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses")
-
- describe "inline mode", ->
- beforeEach ->
- @view = new DiscussionThreadView(
- model: @thread
- el: $("#fixture-element")
- mode: "inline"
- course_settings: DiscussionSpecHelper.makeCourseSettings()
- )
-
- describe "render", ->
- it "shows content that should be visible when collapsed", ->
- @view.render()
- assertExpandedContentVisible(@view, false)
-
- it "does not render any responses by default", ->
- @view.render()
- expect($.ajax).not.toHaveBeenCalled()
- expect(@view.$el.find(".responses li").length).toEqual(0)
-
- describe "focus", ->
- it "sends focus to the conversation when opened", (done) ->
- DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
- @view.render()
- @view.expand()
- self = @
- jasmine.waitUntil(->
- # This is the implementation of "toBeFocused". However, simply calling that method
- # with no wait seems to be flaky.
- article = self.view.$el.find('.discussion-article')
- return article[0] == article[0].ownerDocument.activeElement
- ).then ->
- done()
-
- describe "expand/collapse", ->
- it "shows/hides appropriate content", ->
- DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
- @view.render()
- @view.expand()
- assertExpandedContentVisible(@view, true)
- @view.collapse()
- assertExpandedContentVisible(@view, false)
-
- it "switches between the abbreviated and full body", ->
- DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
- longBody = new Array(100).join("test ")
- expectedAbbreviation = DiscussionUtil.abbreviateString(longBody, 140)
- @thread.set("body", longBody)
-
- @view.render()
- expect($(".post-body").text()).toEqual(expectedAbbreviation)
- expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
- DiscussionThreadShowView.prototype.convertMath.calls.reset()
-
- @view.expand()
- expect($(".post-body").text()).toEqual(longBody)
- expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
- DiscussionThreadShowView.prototype.convertMath.calls.reset()
-
- @view.collapse()
- expect($(".post-body").text()).toEqual(expectedAbbreviation)
- expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
-
- it "strips script tags appropriately", ->
- DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
- longMaliciousBody = new Array(100).join("\n")
- @thread.set("body", longMaliciousBody)
- maliciousAbbreviation = DiscussionUtil.abbreviateString(@thread.get('body'), 140)
-
- # The nodes' html should be different than the strings, but
- # their texts should be the same, indicating that they've been
- # properly escaped. To be safe, make sure the string "");
+ /* jshint +W101 */
+ this.threads = [
+ DiscussionViewSpecHelper.makeThreadWithProps({
+ id: "1",
+ title: "Thread1",
+ votes: {
+ up_count: '20'
+ },
+ pinned: true,
+ comments_count: 1,
+ created_at: '2013-04-03T20:08:39Z'
+ }), DiscussionViewSpecHelper.makeThreadWithProps({
+ id: "2",
+ title: "Thread2",
+ votes: {
+ up_count: '42'
+ },
+ comments_count: 2,
+ created_at: '2013-04-03T20:07:39Z'
+ }), DiscussionViewSpecHelper.makeThreadWithProps({
+ id: "3",
+ title: "Thread3",
+ votes: {
+ up_count: '12'
+ },
+ comments_count: 3,
+ created_at: '2013-04-03T20:06:39Z'
+ }), DiscussionViewSpecHelper.makeThreadWithProps({
+ id: "4",
+ title: "Thread4",
+ votes: {
+ up_count: '25'
+ },
+ comments_count: 0,
+ pinned: true,
+ created_at: '2013-04-03T20:05:39Z'
+ })
+ ];
+ deferred = $.Deferred();
+ spyOn($, "ajax").and.returnValue(deferred);
+ this.discussion = new Discussion([]);
+ this.view = new DiscussionThreadListView({
+ collection: this.discussion,
+ el: $("#fixture-element"),
+ courseSettings: new DiscussionCourseSettings({
+ is_cohorted: true
+ })
+ });
+ return this.view.render();
+ });
+ setupAjax = function(callback) {
+ return $.ajax.and.callFake(function(params) {
+ if (callback) {
+ callback(params);
+ }
+ params.success({
+ discussion_data: [],
+ page: 1,
+ num_pages: 1
+ });
+ return {
+ always: function() {
+ }
+ };
+ });
+ };
+ renderSingleThreadWithProps = function(props) {
+ return makeView(new Discussion([new Thread(DiscussionViewSpecHelper.makeThreadWithProps(props))])).render();
+ };
+ makeView = function(discussion) {
+ return new DiscussionThreadListView({
+ el: $("#fixture-element"),
+ collection: discussion,
+ courseSettings: new DiscussionCourseSettings({
+ is_cohorted: true
+ })
+ });
+ };
+ expectFilter = function(filterVal) {
+ return $.ajax.and.callFake(function(params) {
+ _.each(["unread", "unanswered", "flagged"], function(paramName) {
+ if (paramName === filterVal) {
+ return expect(params.data[paramName]).toEqual(true);
+ } else {
+ return expect(params.data[paramName]).toBeUndefined();
+ }
+ });
+ return {
+ always: function() {
+ }
+ };
+ });
+ };
+
+ describe("should filter correctly", function() {
+ return _.each(["all", "unread", "unanswered", "flagged"], function(filterVal) {
+ it("for " + filterVal, function() {
+ expectFilter(filterVal);
+ this.view.$(".forum-nav-filter-main-control").val(filterVal).change();
+ return expect($.ajax).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe("cohort selector", function() {
+ it("should not be visible to students", function() {
+ return expect(this.view.$(".forum-nav-filter-cohort-control:visible")).not.toExist();
+ });
+ it("should allow moderators to select visibility", function() {
+ var expectedGroupId,
+ self = this;
+ DiscussionSpecHelper.makeModerator();
+ this.view.render();
+ expectedGroupId = null;
+ setupAjax(function(params) {
+ return expect(params.data.group_id).toEqual(expectedGroupId);
+ });
+ return _.each([
+ {
+ val: "",
+ expectedGroupId: void 0
+ }, {
+ val: "1",
+ expectedGroupId: "1"
+ }, {
+ val: "2",
+ expectedGroupId: "2"
+ }
+ ], function(optionInfo) {
+ expectedGroupId = optionInfo.expectedGroupId;
+ self.view.$(".forum-nav-filter-cohort-control").val(optionInfo.val).change();
+ expect($.ajax).toHaveBeenCalled();
+ return $.ajax.calls.reset();
+ });
+ });
+ });
+
+ it("search should clear filter", function() {
+ expectFilter(null);
+ this.view.$(".forum-nav-filter-main-control").val("flagged");
+ this.view.searchFor("foobar");
+ return expect(this.view.$(".forum-nav-filter-main-control").val()).toEqual("all");
+ });
+
+ checkThreadsOrdering = function(view, sort_order, type) {
+ expect(view.$el.find(".forum-nav-thread").children().length).toEqual(4);
+ expect(view.$el.find(".forum-nav-thread:nth-child(1) .forum-nav-thread-title").text())
+ .toEqual(sort_order[0]);
+ expect(view.$el.find(".forum-nav-thread:nth-child(2) .forum-nav-thread-title").text())
+ .toEqual(sort_order[1]);
+ expect(view.$el.find(".forum-nav-thread:nth-child(3) .forum-nav-thread-title").text())
+ .toEqual(sort_order[2]);
+ expect(view.$el.find(".forum-nav-thread:nth-child(4) .forum-nav-thread-title").text())
+ .toEqual(sort_order[3]);
+ return expect(view.$el.find(".forum-nav-sort-control").val()).toEqual(type);
+ };
+
+ describe("thread rendering should be correct", function() {
+ var checkRender;
+ checkRender = function(threads, type, sort_order) {
+ var discussion, view;
+ discussion = new Discussion(_.map(threads, function(thread) {
+ return new Thread(thread);
+ }), {
+ pages: 1,
+ sort: type
+ });
+ view = makeView(discussion);
+ view.render();
+ checkThreadsOrdering(view, sort_order, type);
+ expect(view.$el.find(".forum-nav-thread-comments-count:visible").length)
+ .toEqual(type === "votes" ? 0 : 4);
+ expect(view.$el.find(".forum-nav-thread-votes-count:visible").length)
+ .toEqual(type === "votes" ? 4 : 0);
+ if (type === "votes") {
+ return expect(_.map(view.$el.find(".forum-nav-thread-votes-count"), function(element) {
+ return $(element).text().trim();
+ })).toEqual(["+25 votes", "+20 votes", "+42 votes", "+12 votes"]);
+ }
+ };
+
+ it("with sort preference activity", function() {
+ return checkRender(this.threads, "activity", ["Thread1", "Thread2", "Thread3", "Thread4"]);
+ });
+
+ it("with sort preference votes", function() {
+ return checkRender(this.threads, "votes", ["Thread4", "Thread1", "Thread2", "Thread3"]);
+ });
+
+ it("with sort preference comments", function() {
+ return checkRender(this.threads, "comments", ["Thread1", "Thread4", "Thread3", "Thread2"]);
+ });
+ });
+
+ describe("Sort change should be correct", function() {
+ var changeSorting;
+ changeSorting = function(threads, selected_type, new_type, sort_order) {
+ var discussion, sortControl, sorted_threads, view;
+ discussion = new Discussion(_.map(threads, function(thread) {
+ return new Thread(thread);
+ }), {
+ pages: 1,
+ sort: selected_type
+ });
+ view = makeView(discussion);
+ view.render();
+ sortControl = view.$el.find(".forum-nav-sort-control");
+ expect(sortControl.val()).toEqual(selected_type);
+ sorted_threads = [];
+ if (new_type === 'activity') {
+ sorted_threads = [threads[0], threads[3], threads[1], threads[2]];
+ } else if (new_type === 'comments') {
+ sorted_threads = [threads[0], threads[3], threads[2], threads[1]];
+ } else if (new_type === 'votes') {
+ sorted_threads = [threads[3], threads[0], threads[1], threads[2]];
+ }
+ $.ajax.and.callFake(function(params) {
+ params.success({
+ "discussion_data": sorted_threads,
+ page: 1,
+ num_pages: 1
+ });
+ return {
+ always: function() {
+ }
+ };
+ });
+ sortControl.val(new_type).change();
+ expect($.ajax).toHaveBeenCalled();
+ checkThreadsOrdering(view, sort_order, new_type);
+ };
+
+ it("with sort preference activity", function() {
+ changeSorting(
+ this.threads, "comments", "activity", ["Thread1", "Thread4", "Thread3", "Thread2"]
+ );
+ });
+
+ it("with sort preference votes", function() {
+ changeSorting(this.threads, "activity", "votes", ["Thread4", "Thread1", "Thread2", "Thread3"]);
+ });
+
+ it("with sort preference comments", function() {
+ changeSorting(this.threads, "votes", "comments", ["Thread1", "Thread4", "Thread3", "Thread2"]);
+ });
+ });
+ describe("search alerts", function() {
+ var testAlertMessages;
+
+ testAlertMessages = function(expectedMessages) {
+ return expect($(".search-alert .message").map(function() {
+ return $(this).html();
+ }).get()).toEqual(expectedMessages);
+ };
+
+ it("renders and removes search alerts", function() {
+ var bar, foo;
+ testAlertMessages([]);
+ foo = this.view.addSearchAlert("foo");
+ testAlertMessages(["foo"]);
+ bar = this.view.addSearchAlert("bar");
+ testAlertMessages(["foo", "bar"]);
+ this.view.removeSearchAlert(foo.cid);
+ testAlertMessages(["bar"]);
+ this.view.removeSearchAlert(bar.cid);
+ return testAlertMessages([]);
+ });
+
+ it("clears all search alerts", function() {
+ this.view.addSearchAlert("foo");
+ this.view.addSearchAlert("bar");
+ this.view.addSearchAlert("baz");
+ testAlertMessages(["foo", "bar", "baz"]);
+ this.view.clearSearchAlerts();
+ return testAlertMessages([]);
+ });
+ });
+
+ describe("search spell correction", function() {
+ var testCorrection;
+
+ beforeEach(function() {
+ return spyOn(this.view, "searchForUser");
+ });
+
+ testCorrection = function(view, correctedText) {
+ spyOn(view, "addSearchAlert");
+ $.ajax.and.callFake(function(params) {
+ params.success({
+ discussion_data: [],
+ page: 42,
+ num_pages: 99,
+ corrected_text: correctedText
+ }, 'success');
+ return {
+ always: function() {
+ }
+ };
+ });
+ view.searchFor("dummy");
+ return expect($.ajax).toHaveBeenCalled();
+ };
+
+ it("adds a search alert when an alternate term was searched", function() {
+ testCorrection(this.view, "foo");
+ expect(this.view.addSearchAlert.calls.count()).toEqual(1);
+ return expect(this.view.addSearchAlert.calls.mostRecent().args[0]).toMatch(/foo/);
+ });
+
+ it("does not add a search alert when no alternate term was searched", function() {
+ testCorrection(this.view, null);
+ expect(this.view.addSearchAlert.calls.count()).toEqual(1);
+ return expect(this.view.addSearchAlert.calls.mostRecent().args[0]).toMatch(/no threads matched/i);
+ });
+
+ it("clears search alerts when a new search is performed", function() {
+ spyOn(this.view, "clearSearchAlerts");
+ spyOn(DiscussionUtil, "safeAjax");
+ this.view.searchFor("dummy");
+ return expect(this.view.clearSearchAlerts).toHaveBeenCalled();
+ });
+
+ it("clears search alerts when the underlying collection changes", function() {
+ spyOn(this.view, "clearSearchAlerts");
+ spyOn(this.view, "renderThread");
+ this.view.collection.trigger("change", new Thread({
+ id: 1
+ }));
+ return expect(this.view.clearSearchAlerts).toHaveBeenCalled();
+ });
+ });
+
+ describe("Search events", function() {
+ it("perform search when enter pressed inside search textfield", function() {
+ setupAjax();
+ spyOn(this.view, "searchFor");
+ this.view.$el.find(".forum-nav-search-input").trigger($.Event("keydown", {
+ which: 13
+ }));
+ return expect(this.view.searchFor).toHaveBeenCalled();
+ });
+
+ it("perform search when search icon is clicked", function() {
+ setupAjax();
+ spyOn(this.view, "searchFor");
+ this.view.$el.find(".fa-search").click();
+ return expect(this.view.searchFor).toHaveBeenCalled();
+ });
+ });
+
+ describe("username search", function() {
+ var setAjaxResults;
+
+ it("makes correct ajax calls", function() {
+ $.ajax.and.callFake(function(params) {
+ expect(params.data.username).toEqual("testing-username");
+ expect(params.url.path()).toEqual(DiscussionUtil.urlFor("users"));
+ params.success({
+ users: []
+ }, 'success');
+ return {
+ always: function() {
+ }
+ };
+ });
+ this.view.searchForUser("testing-username");
+ return expect($.ajax).toHaveBeenCalled();
+ });
+
+ setAjaxResults = function(threadSuccess, userResult) {
+ return $.ajax.and.callFake(function(params) {
+ if (params.data.text && threadSuccess) {
+ params.success({
+ discussion_data: [],
+ page: 42,
+ num_pages: 99,
+ corrected_text: "dummy"
+ }, "success");
+ } else if (params.data.username) {
+ params.success({
+ users: userResult
+ }, "success");
+ }
+ return {
+ always: function() {
+ }
+ };
+ });
+ };
+
+ it("gets called after a thread search succeeds", function() {
+ spyOn(this.view, "searchForUser").and.callThrough();
+ setAjaxResults(true, []);
+ this.view.searchFor("gizmo");
+ expect(this.view.searchForUser).toHaveBeenCalled();
+ return expect($.ajax.calls.mostRecent().args[0].data.username).toEqual("gizmo");
+ });
+
+ it("does not get called after a thread search fails", function() {
+ spyOn(this.view, "searchForUser").and.callThrough();
+ setAjaxResults(false, []);
+ this.view.searchFor("gizmo");
+ return expect(this.view.searchForUser).not.toHaveBeenCalled();
+ });
+
+ it("adds a search alert when an username was matched", function() {
+ spyOn(this.view, "addSearchAlert");
+ setAjaxResults(true, [
+ {
+ username: "gizmo",
+ id: "1"
+ }
+ ]);
+ this.view.searchForUser("dummy");
+ expect($.ajax).toHaveBeenCalled();
+ expect(this.view.addSearchAlert).toHaveBeenCalled();
+ return expect(this.view.addSearchAlert.calls.mostRecent().args[0]).toMatch(/gizmo/);
+ });
+
+ it("does not add a search alert when no username was matched", function() {
+ spyOn(this.view, "addSearchAlert");
+ setAjaxResults(true, []);
+ this.view.searchForUser("dummy");
+ expect($.ajax).toHaveBeenCalled();
+ return expect(this.view.addSearchAlert).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("post type renders correctly", function() {
+ it("for discussion", function() {
+ renderSingleThreadWithProps({
+ thread_type: "discussion"
+ });
+ expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-comments");
+ return expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("discussion");
+ });
+
+ it("for answered question", function() {
+ renderSingleThreadWithProps({
+ thread_type: "question",
+ endorsed: true
+ });
+ expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-check-square-o");
+ return expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("answered question");
+ });
+
+ it("for unanswered question", function() {
+ renderSingleThreadWithProps({
+ thread_type: "question",
+ endorsed: false
+ });
+ expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-question");
+ return expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("unanswered question");
+ });
+ });
+
+ describe("post labels render correctly", function() {
+ beforeEach(function() {
+ this.moderatorId = "42";
+ this.administratorId = "43";
+ this.communityTaId = "44";
+ return DiscussionUtil.loadRoles({
+ "Moderator": [parseInt(this.moderatorId)],
+ "Administrator": [parseInt(this.administratorId)],
+ "Community TA": [parseInt(this.communityTaId)]
+ });
+ });
+
+ it("for pinned", function() {
+ renderSingleThreadWithProps({
+ pinned: true
+ });
+ return expect($(".post-label-pinned").length).toEqual(1);
+ });
+
+ it("for following", function() {
+ renderSingleThreadWithProps({
+ subscribed: true
+ });
+ return expect($(".post-label-following").length).toEqual(1);
+ });
+
+ it("for moderator", function() {
+ renderSingleThreadWithProps({
+ user_id: this.moderatorId
+ });
+ return expect($(".post-label-by-staff").length).toEqual(1);
+ });
+
+ it("for administrator", function() {
+ renderSingleThreadWithProps({
+ user_id: this.administratorId
+ });
+ return expect($(".post-label-by-staff").length).toEqual(1);
+ });
+
+ it("for community TA", function() {
+ renderSingleThreadWithProps({
+ user_id: this.communityTaId
+ });
+ return expect($(".post-label-by-community-ta").length).toEqual(1);
+ });
+
+ it("when none should be present", function() {
+ renderSingleThreadWithProps({});
+ return expect($(".forum-nav-thread-labels").length).toEqual(0);
+ });
+ });
+
+ describe("browse menu", function() {
+ var expectBrowseMenuVisible;
+ afterEach(function() {
+ return $("body").unbind("click");
+ });
+
+ expectBrowseMenuVisible = function(isVisible) {
+ expect($(".forum-nav-browse-menu:visible").length).toEqual(isVisible ? 1 : 0);
+ return expect($(".forum-nav-thread-list-wrapper:visible").length).toEqual(isVisible ? 0 : 1);
+ };
+
+ it("should not be visible by default", function() {
+ return expectBrowseMenuVisible(false);
+ });
+
+ it("should show when header button is clicked", function() {
+ $(".forum-nav-browse").click();
+ return expectBrowseMenuVisible(true);
+ });
+
+ describe("when shown", function() {
+ beforeEach(function() {
+ return $(".forum-nav-browse").click();
+ });
+
+ it("should hide when header button is clicked", function() {
+ $(".forum-nav-browse").click();
+ return expectBrowseMenuVisible(false);
+ });
+
+ it("should hide when a click outside the menu occurs", function() {
+ $(".forum-nav-search-input").click();
+ return expectBrowseMenuVisible(false);
+ });
+
+ it("should hide when a search is executed", function() {
+ setupAjax();
+ $(".forum-nav-search-input").trigger($.Event("keydown", {
+ which: 13
+ }));
+ return expectBrowseMenuVisible(false);
+ });
+
+ it("should hide when a category is clicked", function() {
+ $(".forum-nav-browse-title")[0].click();
+ return expectBrowseMenuVisible(false);
+ });
+
+ it("should still be shown when filter input is clicked", function() {
+ $(".forum-nav-browse-filter-input").click();
+ return expectBrowseMenuVisible(true);
+ });
+
+ describe("filtering", function() {
+ var checkFilter;
+ checkFilter = function(filterText, expectedItems) {
+ var visibleItems;
+ $(".forum-nav-browse-filter-input").val(filterText).keyup();
+ visibleItems = $(".forum-nav-browse-title:visible").map(function(i, elem) {
+ return $(elem).text();
+ }).get();
+ return expect(visibleItems).toEqual(expectedItems);
+ };
+
+ it("should be case-insensitive", function() {
+ return checkFilter("other", ["Other Category"]);
+ });
+
+ it("should match partial words", function() {
+ return checkFilter("ateg", ["Other Category"]);
+ });
+
+ it("should show ancestors and descendants of matches", function() {
+ return checkFilter("Target", ["Parent", "Target", "Child"]);
+ });
+
+ it("should handle multiple words regardless of order", function() {
+ return checkFilter("Following Posts", ["Posts I'm Following"]);
+ });
+
+ it("should handle multiple words in different depths", function() {
+ return checkFilter("Parent Child", ["Parent", "Target", "Child"]);
+ });
+ });
+ });
+
+ describe("selecting an item", function() {
+ var testSelectionRequest;
+
+ it("should clear the search box", function() {
+ setupAjax();
+ $(".forum-nav-search-input").val("foobar");
+ $(".forum-nav-browse-menu-following .forum-nav-browse-title").click();
+ return expect($(".forum-nav-search-input").val()).toEqual("");
+ });
+
+ it("should change the button text", function() {
+ setupAjax();
+ $(".forum-nav-browse-menu-following .forum-nav-browse-title").click();
+ return expect($(".forum-nav-browse-current").text()).toEqual("Posts I'm Following");
+ });
+
+ it("should show/hide the cohort selector", function() {
+ var self = this;
+ DiscussionSpecHelper.makeModerator();
+ this.view.render();
+ setupAjax();
+ return _.each([
+ {
+ selector: ".forum-nav-browse-menu-all",
+ cohortVisibility: true
+ }, {
+ selector: ".forum-nav-browse-menu-following",
+ cohortVisibility: false
+ }, {
+ selector: ".forum-nav-browse-menu-item:" +
+ "has(.forum-nav-browse-menu-item .forum-nav-browse-menu-item)",
+ cohortVisibility: false
+ }, {
+ selector: "[data-discussion-id=child]",
+ cohortVisibility: false
+ }, {
+ selector: "[data-discussion-id=other]",
+ cohortVisibility: true
+ }
+ ], function(itemInfo) {
+ self.view.$("" + itemInfo.selector + " > .forum-nav-browse-title").click();
+ return expect(self.view.$(".forum-nav-filter-cohort").is(":visible"))
+ .toEqual(itemInfo.cohortVisibility);
+ });
+ });
+
+ testSelectionRequest = function(callback, itemText) {
+ setupAjax(callback);
+ $(".forum-nav-browse-title:contains(" + itemText + ")").click();
+ return expect($.ajax).toHaveBeenCalled();
+ };
+
+ it("should get all discussions", function() {
+ return testSelectionRequest(function(params) {
+ return expect(params.url.path()).toEqual(DiscussionUtil.urlFor("threads"));
+ }, "All");
+ });
+
+ it("should get followed threads", function() {
+ testSelectionRequest(function(params) {
+ return expect(params.url.path())
+ .toEqual(DiscussionUtil.urlFor("followed_threads", window.user.id));
+ }, "Following");
+ return expect($.ajax.calls.mostRecent().args[0].data.group_id).toBeUndefined();
+ });
+
+ it("should get threads for the selected leaf", function() {
+ return testSelectionRequest(function(params) {
+ expect(params.url.path()).toEqual(DiscussionUtil.urlFor("search"));
+ return expect(params.data.commentable_ids).toEqual("child");
+ }, "Child");
+ });
+
+ it("should get threads for children of the selected intermediate node", function() {
+ return testSelectionRequest(function(params) {
+ expect(params.url.path()).toEqual(DiscussionUtil.urlFor("search"));
+ return expect(params.data.commentable_ids).toEqual("child,sibling");
+ }, "Parent");
+ });
+ });
+ });
+ });
+}).call(this);
diff --git a/common/static/common/js/spec/discussion/view/discussion_thread_profile_view_spec.js b/common/static/common/js/spec/discussion/view/discussion_thread_profile_view_spec.js
new file mode 100644
index 0000000000..1f72753efd
--- /dev/null
+++ b/common/static/common/js/spec/discussion/view/discussion_thread_profile_view_spec.js
@@ -0,0 +1,156 @@
+/* globals Discussion, DiscussionSpecHelper, DiscussionThreadProfileView, Thread */
+(function() {
+ 'use strict';
+ describe("DiscussionThreadProfileView", function() {
+ var checkBody, checkPostWithImages, makeThread, makeView, spyConvertMath;
+ beforeEach(function() {
+ DiscussionSpecHelper.setUpGlobals();
+ DiscussionSpecHelper.setUnderscoreFixtures();
+ this.threadData = {
+ id: "1",
+ body: "dummy body",
+ discussion: new Discussion(),
+ abuse_flaggers: [],
+ commentable_id: 'dummy_discussion',
+ votes: {
+ up_count: "42"
+ },
+ created_at: "2014-09-09T20:11:08Z"
+ };
+ this.imageTag = '
';
+ window.MathJax = {
+ Hub: {
+ Queue: function() {
+ }
+ }
+ };
+ });
+ makeView = function(thread) {
+ var view;
+ view = new DiscussionThreadProfileView({
+ model: thread
+ });
+ spyConvertMath(view);
+ return view;
+ };
+ makeThread = function(threadData) {
+ var thread;
+ thread = new Thread(threadData);
+ thread.discussion = new Discussion();
+ return thread;
+ };
+ spyConvertMath = function(view) {
+ return spyOn(view, "convertMath").and.callFake(function() {
+ return this.model.set('markdownBody', this.model.get('body'));
+ });
+ };
+ checkPostWithImages = function(numberOfImages, truncatedText, threadData, imageTag) {
+ var expectedHtml, expectedText, i, testText, view, _i, _ref;
+ expectedHtml = '';
+ threadData.body = '
';
+ testText = '';
+ expectedText = '';
+ if (truncatedText) {
+ testText = new Array(100).join('test ');
+ expectedText = testText.substring(0, 139) + '…';
+ } else {
+ testText = 'Test body';
+ expectedText = 'Test body';
+ // I really have no idea what it is supposed to mean - probably just iteration, but better be safe
+ for (
+ i = _i = 0, _ref = numberOfImages - 1;
+ 0 <= _ref ? _i <= _ref : _i >= _ref;
+ i = 0 <= _ref ? ++_i : --_i
+ ) {
+ threadData.body = threadData.body + imageTag;
+ if (i === 0) {
+ expectedHtml = expectedHtml + imageTag;
+ } else {
+ expectedHtml = expectedHtml + 'image omitted';
+ }
+ }
+ }
+ threadData.body = threadData.body + '' + testText + '
';
+ if (numberOfImages > 1) {
+ expectedHtml = expectedHtml + '' + expectedText +
+ 'Some images in this post have been omitted
';
+ } else {
+ expectedHtml = expectedHtml + '' + expectedText + '';
+ }
+ view = makeView(makeThread(threadData));
+ view.render();
+ return expect(view.$el.find(".post-body").html()).toEqual(expectedHtml);
+ };
+ checkBody = function(truncated, view, threadData) {
+ var expectedOutput, inputHtmlStripped, outputHtmlStripped;
+ view.render();
+ if (!truncated) {
+ expect(view.model.get("body")).toEqual(view.model.get("abbreviatedBody"));
+ return expect(view.$el.find(".post-body").html()).toEqual(threadData.body);
+ } else {
+ expect(view.model.get("body")).not.toEqual(view.model.get("abbreviatedBody"));
+ expect(view.$el.find(".post-body").html()).not.toEqual(threadData.body);
+ outputHtmlStripped = view.$el.find(".post-body").html().replace(/(<([^>]+)>)/ig, "");
+ outputHtmlStripped = outputHtmlStripped.replace("Some images in this post have been omitted", "");
+ outputHtmlStripped = outputHtmlStripped.replace("image omitted", "");
+ inputHtmlStripped = threadData.body.replace(/(<([^>]+)>)/ig, "");
+ expectedOutput = inputHtmlStripped.substring(0, 139) + '…';
+ expect(outputHtmlStripped).toEqual(expectedOutput);
+ return expect(view.$el.find(".post-body").html().indexOf("…")).toBeGreaterThan(0);
+ }
+ };
+ describe("Body markdown should be correct", function() {
+ var numImages, truncatedText, _i, _j, _len, _len1, _ref, _ref1;
+ it("untruncated text without markdown body", function() {
+ var view;
+ this.threadData.body = "Test body";
+ view = makeView(makeThread(this.threadData));
+ return checkBody(false, view, this.threadData);
+ });
+ it("truncated text without markdown body", function() {
+ var view;
+ this.threadData.body = new Array(100).join("test ");
+ view = makeView(makeThread(this.threadData));
+ return checkBody(true, view, this.threadData);
+ });
+ it("untruncated text with markdown body", function() {
+ var view;
+ this.threadData.body = '' + this.imageTag + 'Google top search engine
';
+ view = makeView(makeThread(this.threadData));
+ return checkBody(false, view, this.threadData);
+ });
+ it("truncated text with markdown body", function() {
+ var testText, view;
+ testText = new Array(100).join("test ");
+ this.threadData.body = '' + this.imageTag + this.imageTag + '' + testText + '
';
+ view = makeView(makeThread(this.threadData));
+ return checkBody(true, view, this.threadData);
+ });
+ _ref = [1, 2, 10];
+ for (_i = 0, _len = _ref.length; _i < _len; _i++) {
+ numImages = _ref[_i];
+ _ref1 = [true, false];
+ for (_j = 0, _len1 = _ref1.length; _j < _len1; _j++) {
+ truncatedText = _ref1[_j];
+ it(
+ "body with " + numImages + " images and " + (truncatedText ? "truncated" : "untruncated") +
+ " text",
+ // suppressing Don't make functions within a loop.
+ /* jshint -W083 */
+ function() {
+ return checkPostWithImages(numImages, truncatedText, this.threadData, this.imageTag);
+ }
+ /* jshint +W083 */
+ );
+ }
+ }
+ it("check the thread retrieve url", function() {
+ var thread;
+ thread = makeThread(this.threadData);
+ return expect(thread.urlFor('retrieve'))
+ .toBe('/courses/edX/999/test/discussion/forum/dummy_discussion/threads/1');
+ });
+ });
+ });
+
+}).call(this);
diff --git a/common/static/common/js/spec/discussion/view/discussion_thread_show_view_spec.js b/common/static/common/js/spec/discussion/view/discussion_thread_show_view_spec.js
new file mode 100644
index 0000000000..7e9df2b269
--- /dev/null
+++ b/common/static/common/js/spec/discussion/view/discussion_thread_show_view_spec.js
@@ -0,0 +1,191 @@
+/* globals DiscussionSpecHelper, DiscussionThreadShowView, DiscussionUtil, DiscussionViewSpecHelper, Thread */
+(function() {
+ 'use strict';
+
+ var $$course_id = "$$course_id";
+ describe("DiscussionThreadShowView", function() {
+ beforeEach(function() {
+ DiscussionSpecHelper.setUpGlobals();
+ DiscussionSpecHelper.setUnderscoreFixtures();
+ this.user = DiscussionUtil.getUser();
+ this.threadData = {
+ id: "dummy",
+ user_id: this.user.id,
+ username: this.user.get('username'),
+ course_id: $$course_id,
+ title: "dummy title",
+ body: "this is a thread",
+ created_at: "2013-04-03T20:08:39Z",
+ abuse_flaggers: [],
+ votes: {
+ up_count: 42
+ },
+ thread_type: "discussion",
+ closed: false,
+ pinned: false,
+ type: "thread"
+ };
+ this.thread = new Thread(this.threadData);
+ this.view = new DiscussionThreadShowView({
+ model: this.thread
+ });
+ this.view.setElement($("#fixture-element"));
+ return spyOn(this.view, "convertMath");
+ });
+ describe("voting", function() {
+ it("renders the vote state correctly", function() {
+ return DiscussionViewSpecHelper.checkRenderVote(this.view, this.thread);
+ });
+ it("votes correctly via click", function() {
+ return DiscussionViewSpecHelper.checkUpvote(this.view, this.thread, this.user, $.Event("click"));
+ });
+ it("votes correctly via spacebar", function() {
+ return DiscussionViewSpecHelper.checkUpvote(this.view, this.thread, this.user, $.Event("keydown", {
+ which: 32
+ }));
+ });
+ it("unvotes correctly via click", function() {
+ return DiscussionViewSpecHelper.checkUnvote(this.view, this.thread, this.user, $.Event("click"));
+ });
+ it("unvotes correctly via spacebar", function() {
+ return DiscussionViewSpecHelper.checkUnvote(this.view, this.thread, this.user, $.Event("keydown", {
+ which: 32
+ }));
+ });
+ });
+ describe("pinning", function() {
+ var expectPinnedRendered;
+ expectPinnedRendered = function(view, model) {
+ var button, pinned;
+ pinned = model.get('pinned');
+ button = view.$el.find(".action-pin");
+ expect(button.hasClass("is-checked")).toBe(pinned);
+ return expect(button.attr("aria-checked")).toEqual(pinned.toString());
+ };
+ it("renders the pinned state correctly", function() {
+ this.view.render();
+ expectPinnedRendered(this.view, this.thread);
+ this.thread.set('pinned', false);
+ this.view.render();
+ expectPinnedRendered(this.view, this.thread);
+ this.thread.set('pinned', true);
+ this.view.render();
+ return expectPinnedRendered(this.view, this.thread);
+ });
+ it("exposes the pinning control only to authorized users", function() {
+ this.thread.updateInfo({
+ ability: {
+ can_openclose: false
+ }
+ });
+ this.view.render();
+ expect(this.view.$el.find(".action-pin").closest(".is-hidden")).toExist();
+ this.thread.updateInfo({
+ ability: {
+ can_openclose: true
+ }
+ });
+ this.view.render();
+ return expect(this.view.$el.find(".action-pin").closest(".is-hidden")).not.toExist();
+ });
+ it("handles events correctly", function() {
+ this.view.render();
+ return DiscussionViewSpecHelper.checkButtonEvents(this.view, "togglePin", ".action-pin");
+ });
+ });
+ describe("labels", function() {
+ var expectOneElement;
+ expectOneElement = function(view, selector, visible) {
+ var elements;
+ if (typeof visible === "undefined" || visible === null) {
+ visible = true;
+ }
+ view.render();
+ elements = view.$el.find(selector);
+ expect(elements.length).toEqual(1);
+ if (visible) {
+ return expect(elements).not.toHaveClass("is-hidden");
+ } else {
+ return expect(elements).toHaveClass("is-hidden");
+ }
+ };
+ it('displays the closed label when appropriate', function() {
+ expectOneElement(this.view, '.post-label-closed', false);
+ this.thread.set('closed', true);
+ return expectOneElement(this.view, '.post-label-closed');
+ });
+ it('displays the pinned label when appropriate', function() {
+ expectOneElement(this.view, '.post-label-pinned', false);
+ this.thread.set('pinned', true);
+ return expectOneElement(this.view, '.post-label-pinned');
+ });
+ it('displays the reported label when appropriate for a non-staff user', function() {
+ expectOneElement(this.view, '.post-label-reported', false);
+ this.thread.set('abuse_flaggers', [DiscussionUtil.getUser().id]);
+ expectOneElement(this.view, '.post-label-reported');
+ this.thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1]);
+ return expectOneElement(this.view, '.post-label-reported', false);
+ });
+ it('displays the reported label when appropriate for a flag moderator', function() {
+ DiscussionSpecHelper.makeModerator();
+ expectOneElement(this.view, '.post-label-reported', false);
+ this.thread.set('abuse_flaggers', [DiscussionUtil.getUser().id]);
+ expectOneElement(this.view, '.post-label-reported');
+ this.thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1]);
+ return expectOneElement(this.view, '.post-label-reported');
+ });
+ });
+ describe("author display", function() {
+ var checkUserLink;
+ beforeEach(function() {
+ return this.thread.set('user_url', 'test_user_url');
+ });
+ checkUserLink = function(element, is_ta, is_staff) {
+ expect(element.find('a.username').length).toEqual(1);
+ expect(element.find('a.username').text()).toEqual('test_user');
+ expect(element.find('a.username').attr('href')).toEqual('test_user_url');
+ expect(element.find('.user-label-community-ta').length).toEqual(is_ta ? 1 : 0);
+ return expect(element.find('.user-label-staff').length).toEqual(is_staff ? 1 : 0);
+ };
+ it("renders correctly for a student-authored thread", function() {
+ var $el;
+ $el = $('#fixture-element').html(this.view.getAuthorDisplay());
+ return checkUserLink($el, false, false);
+ });
+ it("renders correctly for a community TA-authored thread", function() {
+ var $el;
+ this.thread.set('community_ta_authored', true);
+ $el = $('#fixture-element').html(this.view.getAuthorDisplay());
+ return checkUserLink($el, true, false);
+ });
+ it("renders correctly for a staff-authored thread", function() {
+ var $el;
+ this.thread.set('staff_authored', true);
+ $el = $('#fixture-element').html(this.view.getAuthorDisplay());
+ return checkUserLink($el, false, true);
+ });
+ it("renders correctly for an anonymously-authored thread", function() {
+ var $el;
+ this.thread.set('username', null);
+ $el = $('#fixture-element').html(this.view.getAuthorDisplay());
+ expect($el.find('a.username').length).toEqual(0);
+ return expect($el.text()).toMatch(/^(\s*)anonymous(\s*)$/);
+ });
+ });
+ describe("cohorting", function() {
+ it("renders correctly for an uncohorted thread", function() {
+ this.view.render();
+ return expect(this.view.$('.group-visibility-label').text().trim())
+ .toEqual('This post is visible to everyone.');
+ });
+ it("renders correctly for a cohorted thread", function() {
+ this.thread.set('group_id', '1');
+ this.thread.set('group_name', 'Mock Cohort');
+ this.view.render();
+ return expect(this.view.$('.group-visibility-label').text().trim())
+ .toEqual('This post is visible only to Mock Cohort.');
+ });
+ });
+ });
+
+}).call(this);
diff --git a/common/static/common/js/spec/discussion/view/discussion_thread_view_spec.js b/common/static/common/js/spec/discussion/view/discussion_thread_view_spec.js
new file mode 100644
index 0000000000..cb7b7a346e
--- /dev/null
+++ b/common/static/common/js/spec/discussion/view/discussion_thread_view_spec.js
@@ -0,0 +1,482 @@
+/* global
+ Discussion, DiscussionThreadShowView, DiscussionViewSpecHelper, DiscussionSpecHelper, DiscussionThreadView,
+ DiscussionUtil, Thread, DiscussionContentView, ThreadResponseShowView
+*/
+(function() {
+ 'use strict';
+ describe("DiscussionThreadView", function() {
+ var assertContentVisible, assertExpandedContentVisible, assertResponseCountAndPaginationCorrect,
+ createAjaxResponseJson, createTestResponseJson, postResponse, renderWithContent, renderWithTestResponses;
+ beforeEach(function() {
+ var deferred;
+ DiscussionSpecHelper.setUpGlobals();
+ DiscussionSpecHelper.setUnderscoreFixtures();
+ jasmine.clock().install();
+ this.threadData = DiscussionViewSpecHelper.makeThreadWithProps({});
+ this.thread = new Thread(this.threadData);
+ this.discussion = new Discussion(this.thread);
+ deferred = $.Deferred();
+ spyOn($, "ajax").and.returnValue(deferred);
+ spyOn(DiscussionThreadShowView.prototype, "convertMath");
+ spyOn(DiscussionContentView.prototype, "makeWmdEditor");
+ spyOn(DiscussionUtil, "makeWmdEditor");
+ spyOn(DiscussionUtil, "setWmdContent");
+ return spyOn(ThreadResponseShowView.prototype, "convertMath");
+ });
+ afterEach(function() {
+ $.ajax.calls.reset();
+ return jasmine.clock().uninstall();
+ });
+ renderWithContent = function(view, content) {
+ $.ajax.and.callFake(function(params) {
+ params.success(createAjaxResponseJson(content, false), 'success');
+ return {
+ always: function() {
+ }
+ };
+ });
+ view.render();
+ return jasmine.clock().tick(100);
+ };
+ renderWithTestResponses = function(view, count, options) {
+ var index;
+ return renderWithContent(view, _.extend({
+ resp_total: count,
+ children: count > 0 ? (function() {
+ var _i, _results;
+ _results = [];
+ for (index = _i = 1; 1 <= count ? _i <= count : _i >= count; index = 1 <= count ? ++_i : --_i) {
+ _results.push(createTestResponseJson(index));
+ }
+ return _results;
+ })() : []
+ }, options));
+ };
+ createTestResponseJson = function(index) {
+ return {
+ user_id: window.user.id,
+ body: "Response " + index,
+ id: "id_" + index,
+ created_at: "2015-01-01T22:20:28Z"
+ };
+ };
+ assertContentVisible = function(view, selector, visible) {
+ var content;
+ content = view.$el.find(selector);
+ expect(content.length).toBeGreaterThan(0);
+ return content.each(function(i, elem) {
+ return expect($(elem).is(":visible")).toEqual(visible);
+ });
+ };
+ assertExpandedContentVisible = function(view, expanded) {
+ expect(view.$el.hasClass("expanded")).toEqual(expanded);
+ assertContentVisible(view, ".post-extended-content", expanded);
+ assertContentVisible(view, ".forum-thread-expand", !expanded);
+ return assertContentVisible(view, ".forum-thread-collapse", expanded);
+ };
+ assertResponseCountAndPaginationCorrect = function(view, countText, displayCountText, buttonText) {
+ expect(view.$el.find(".response-count").text()).toEqual(countText);
+ if (displayCountText) {
+ expect(view.$el.find(".response-display-count").text()).toEqual(displayCountText);
+ } else {
+ expect(view.$el.find(".response-display-count").length).toEqual(0);
+ }
+ if (buttonText) {
+ return expect(view.$el.find(".load-response-button").text()).toEqual(buttonText);
+ } else {
+ return expect(view.$el.find(".load-response-button").length).toEqual(0);
+ }
+ };
+ createAjaxResponseJson = function(content, can_act) {
+ return {
+ content: content,
+ annotated_content_info: {
+ ability: {
+ editable: can_act,
+ can_delete: can_act,
+ can_reply: can_act,
+ can_vote: can_act
+ }
+ }
+ };
+ };
+ postResponse = function(view, index) {
+ var responseText, testResponseJson;
+ testResponseJson = createTestResponseJson(index);
+ responseText = testResponseJson.body;
+ spyOn(view, "getWmdContent").and.returnValue(responseText);
+ $.ajax.and.callFake(function(params) {
+ expect(params.type).toEqual("POST");
+ expect(params.data.body).toEqual(responseText);
+ params.success(createAjaxResponseJson(testResponseJson, true), 'success');
+ return {
+ always: function() {
+ }
+ };
+ });
+ return view.$(".discussion-submit-post").click();
+ };
+ describe("closed and open Threads", function() {
+ var checkCommentForm, checkVoteDisplay, createDiscussionThreadView;
+ createDiscussionThreadView = function(originallyClosed, mode) {
+ var discussion, thread, threadData, view;
+ threadData = DiscussionViewSpecHelper.makeThreadWithProps({
+ closed: originallyClosed
+ });
+ thread = new Thread(threadData);
+ discussion = new Discussion(thread);
+ view = new DiscussionThreadView({
+ model: thread,
+ el: $("#fixture-element"),
+ mode: mode,
+ course_settings: DiscussionSpecHelper.makeCourseSettings()
+ });
+ renderWithTestResponses(view, 1);
+ if (mode === "inline") {
+ view.expand();
+ }
+ spyOn(DiscussionUtil, "updateWithUndo").and.callFake(function(model, updates) {
+ return model.set(updates);
+ });
+ return view;
+ };
+ checkCommentForm = function(originallyClosed, mode) {
+ var view;
+ view = createDiscussionThreadView(originallyClosed, mode);
+ expect(view.$('.comment-form').closest('li').is(":visible")).toBe(!originallyClosed);
+ expect(view.$(".discussion-reply-new").is(":visible")).toBe(!originallyClosed);
+ view.$(".action-close").click();
+ expect(view.$('.comment-form').closest('li').is(":visible")).toBe(originallyClosed);
+ return expect(view.$(".discussion-reply-new").is(":visible")).toBe(originallyClosed);
+ };
+ checkVoteDisplay = function(originallyClosed, mode) {
+ var view;
+ view = createDiscussionThreadView(originallyClosed, mode);
+ expect(view.$('.thread-main-wrapper .action-vote').is(":visible")).toBe(!originallyClosed);
+ expect(view.$('.thread-main-wrapper .display-vote').is(":visible")).toBe(originallyClosed);
+ view.$(".action-close").click();
+ expect(view.$('.action-vote').is(":visible")).toBe(originallyClosed);
+ return expect(view.$('.display-vote').is(":visible")).toBe(!originallyClosed);
+ };
+ return _.each(["tab", "inline"], function(mode) {
+ it(
+ "Test that in " + mode + " mode when a closed thread is opened the comment form is displayed",
+ function() { return checkCommentForm(true, mode); }
+ );
+ it(
+ "Test that in " + mode + " mode when a open thread is closed the comment form is hidden",
+ function() { return checkCommentForm(false, mode); }
+ );
+ it(
+ "Test that in " + mode + " mode when a closed thread is opened the vote button is displayed and " +
+ "vote count is hidden",
+ function() { return checkVoteDisplay(true, mode); }
+ );
+ it(
+ "Test that in " + mode + " mode when a open thread is closed the vote button is hidden and " +
+ "vote count is displayed",
+ function() { return checkVoteDisplay(false, mode); }
+ );
+ });
+ });
+ describe("tab mode", function() {
+ beforeEach(function() {
+ this.view = new DiscussionThreadView({
+ model: this.thread,
+ el: $("#fixture-element"),
+ mode: "tab",
+ course_settings: DiscussionSpecHelper.makeCourseSettings()
+ });
+ });
+ describe("responses", function() {
+ it("can post a first response", function() {
+ renderWithTestResponses(this.view, 0);
+ postResponse(this.view, 1);
+ expect(this.view.$(".forum-response").length).toBe(1);
+ expect(this.view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1);
+ return expect(this.view.$(".response-actions-list").find(".action-edit")
+ .parent().not(".is-hidden").length).toBe(1);
+ });
+ it("can post a second response", function() {
+ renderWithTestResponses(this.view, 1);
+ expect(this.view.$(".forum-response").length).toBe(1);
+ expect(this.view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1);
+ expect(this.view.$(".response-actions-list").find(".action-edit").parent()
+ .not(".is-hidden").length).toBe(1);
+ postResponse(this.view, 2);
+ expect(this.view.$(".forum-response").length).toBe(2);
+ expect(this.view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1);
+ return expect(this.view.$(".response-actions-list").find(".action-edit").parent()
+ .not(".is-hidden").length).toBe(2);
+ });
+ });
+ describe("response count and pagination", function() {
+ it("correctly render for a thread with no responses", function() {
+ renderWithTestResponses(this.view, 0);
+ return assertResponseCountAndPaginationCorrect(this.view, "0 responses", null, null);
+ });
+ it("correctly render for a thread with one response", function() {
+ renderWithTestResponses(this.view, 1);
+ return assertResponseCountAndPaginationCorrect(
+ this.view, "1 response", "Showing all responses", null
+ );
+ });
+ it("correctly render for a thread with one additional page", function() {
+ renderWithTestResponses(this.view, 1, {
+ resp_total: 2
+ });
+ return assertResponseCountAndPaginationCorrect(
+ this.view, "2 responses", "Showing first response", "Load all responses"
+ );
+ });
+ it("correctly render for a thread with multiple additional pages", function() {
+ renderWithTestResponses(this.view, 2, {
+ resp_total: 111
+ });
+ return assertResponseCountAndPaginationCorrect(
+ this.view, "111 responses", "Showing first 2 responses", "Load next 100 responses"
+ );
+ });
+ describe("on clicking the load more button", function() {
+ beforeEach(function() {
+ renderWithTestResponses(this.view, 1, {
+ resp_total: 5
+ });
+ return assertResponseCountAndPaginationCorrect(
+ this.view, "5 responses", "Showing first response", "Load all responses"
+ );
+ });
+ it("correctly re-render when all threads have loaded", function() {
+ renderWithTestResponses(this.view, 5, {
+ resp_total: 5
+ });
+ this.view.$el.find(".load-response-button").click();
+ return assertResponseCountAndPaginationCorrect(
+ this.view, "5 responses", "Showing all responses", null
+ );
+ });
+ it("correctly re-render when one page remains", function() {
+ renderWithTestResponses(this.view, 3, {
+ resp_total: 42
+ });
+ this.view.$el.find(".load-response-button").click();
+ return assertResponseCountAndPaginationCorrect(
+ this.view, "42 responses", "Showing first 3 responses", "Load all responses"
+ );
+ });
+ it("correctly re-render when multiple pages remain", function() {
+ renderWithTestResponses(this.view, 3, {
+ resp_total: 111
+ });
+ this.view.$el.find(".load-response-button").click();
+ return assertResponseCountAndPaginationCorrect(
+ this.view, "111 responses", "Showing first 3 responses", "Load next 100 responses"
+ );
+ });
+ });
+ });
+ });
+ describe("inline mode", function() {
+ beforeEach(function() {
+ this.view = new DiscussionThreadView({
+ model: this.thread,
+ el: $("#fixture-element"),
+ mode: "inline",
+ course_settings: DiscussionSpecHelper.makeCourseSettings()
+ });
+ });
+ describe("render", function() {
+ it("shows content that should be visible when collapsed", function() {
+ this.view.render();
+ return assertExpandedContentVisible(this.view, false);
+ });
+ it("does not render any responses by default", function() {
+ this.view.render();
+ expect($.ajax).not.toHaveBeenCalled();
+ return expect(this.view.$el.find(".responses li").length).toEqual(0);
+ });
+ });
+ describe("focus", function() {
+ it("sends focus to the conversation when opened", function(done) {
+ var self;
+ DiscussionViewSpecHelper.setNextResponseContent({
+ resp_total: 0,
+ children: []
+ });
+ this.view.render();
+ this.view.expand();
+ self = this;
+ return jasmine.waitUntil(function() {
+ var article;
+ article = self.view.$el.find('.discussion-article');
+ return article[0] === article[0].ownerDocument.activeElement;
+ }).then(function() {
+ return done();
+ });
+ });
+ });
+ describe("expand/collapse", function() {
+ it("shows/hides appropriate content", function() {
+ DiscussionViewSpecHelper.setNextResponseContent({
+ resp_total: 0,
+ children: []
+ });
+ this.view.render();
+ this.view.expand();
+ assertExpandedContentVisible(this.view, true);
+ this.view.collapse();
+ return assertExpandedContentVisible(this.view, false);
+ });
+ it("switches between the abbreviated and full body", function() {
+ var expectedAbbreviation, longBody;
+ DiscussionViewSpecHelper.setNextResponseContent({
+ resp_total: 0,
+ children: []
+ });
+ longBody = new Array(100).join("test ");
+ expectedAbbreviation = DiscussionUtil.abbreviateString(longBody, 140);
+ this.thread.set("body", longBody);
+ this.view.render();
+ expect($(".post-body").text()).toEqual(expectedAbbreviation);
+ expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled();
+ DiscussionThreadShowView.prototype.convertMath.calls.reset();
+ this.view.expand();
+ expect($(".post-body").text()).toEqual(longBody);
+ expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled();
+ DiscussionThreadShowView.prototype.convertMath.calls.reset();
+ this.view.collapse();
+ expect($(".post-body").text()).toEqual(expectedAbbreviation);
+ return expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled();
+ });
+ it("strips script tags appropriately", function() {
+ var longMaliciousBody, maliciousAbbreviation;
+ DiscussionViewSpecHelper.setNextResponseContent({
+ resp_total: 0,
+ children: []
+ });
+ longMaliciousBody = new Array(100).join(
+ "\n"
+ );
+ this.thread.set("body", longMaliciousBody);
+ maliciousAbbreviation = DiscussionUtil.abbreviateString(this.thread.get('body'), 140);
+ this.view.render();
+ expect($(".post-body").html()).not.toEqual(maliciousAbbreviation);
+ expect($(".post-body").text()).toEqual(maliciousAbbreviation);
+ expect($(".post-body").html()).not.toContain("