+%block>
diff --git a/cms/urls.py b/cms/urls.py
index 7e06f5033909..0f0a898e98b3 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -110,6 +110,12 @@
url(r'^i18n.js$', 'django.views.i18n.javascript_catalog', js_info_dict),
)
+if settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES'):
+ LIBRARY_KEY_PATTERN = r'(?Plibrary-v1:[^/+]+\+[^/+]+)'
+ urlpatterns += (
+ url(r'^library/{}?$'.format(LIBRARY_KEY_PATTERN),
+ 'contentstore.views.library_handler', name='library_handler'),
+ )
if settings.FEATURES.get('ENABLE_EXPORT_GIT'):
urlpatterns += (url(
diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py
index bb59da2a7d76..12c2f4c84b0f 100644
--- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py
+++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split_draft.py
@@ -5,7 +5,7 @@
from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore, EXCLUDE_ALL
from xmodule.exceptions import InvalidVersionError
from xmodule.modulestore import ModuleStoreEnum
-from xmodule.modulestore.exceptions import InsufficientSpecificationError
+from xmodule.modulestore.exceptions import InsufficientSpecificationError, ItemNotFoundError
from xmodule.modulestore.draft_and_published import (
ModuleStoreDraftAndPublished, DIRECT_ONLY_CATEGORIES, UnsupportedRevisionError
)
@@ -409,7 +409,11 @@ def convert_to_draft(self, location, user_id):
pass
def _get_head(self, xblock, branch):
- course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure
+ try:
+ course_structure = self._lookup_course(xblock.location.course_key.for_branch(branch)).structure
+ except ItemNotFoundError:
+ # There is no published version xblock container, e.g. Library
+ return None
return self._get_block_from_structure(course_structure, BlockKey.from_usage_key(xblock.location))
def _get_version(self, block):
diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py
index 1447b0238414..13df157602e9 100644
--- a/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py
+++ b/common/lib/xmodule/xmodule/modulestore/tests/test_libraries.py
@@ -206,3 +206,14 @@ def test_library_author_view(self):
with patch('xmodule.x_module.descriptor_global_get_asides', lambda block: []):
result = library.render(AUTHOR_VIEW, context)
self.assertIn(message, result.content)
+
+ def test_xblock_in_lib_have_published_version_returns_false(self):
+ library = LibraryFactory.create(modulestore=self.store)
+ block = ItemFactory.create(
+ category="html",
+ parent_location=library.location,
+ user_id=self.user_id,
+ publish_item=False,
+ modulestore=self.store,
+ )
+ self.assertFalse(self.store.has_published_version(block))
diff --git a/common/static/js/xblock/core.js b/common/static/js/xblock/core.js
index ffef2b27627b..99b2ae0489b0 100644
--- a/common/static/js/xblock/core.js
+++ b/common/static/js/xblock/core.js
@@ -23,10 +23,10 @@
if (runtime && version && initFnName) {
return new window[runtime]['v' + version];
} else {
- if (!runtime || !version || !initFnName) {
+ if (runtime || version || initFnName) {
var elementTag = $('
').append($element.clone()).html();
console.log('Block ' + elementTag + ' is missing data-runtime, data-runtime-version or data-init, and can\'t be initialized');
- }
+ } // else this XBlock doesn't have a JS init function.
return null;
}
}
diff --git a/common/test/acceptance/fixtures/base.py b/common/test/acceptance/fixtures/base.py
new file mode 100644
index 000000000000..0f2e2723839e
--- /dev/null
+++ b/common/test/acceptance/fixtures/base.py
@@ -0,0 +1,196 @@
+"""
+Common code shared by course and library fixtures.
+"""
+import re
+import requests
+import json
+from lazy import lazy
+
+from . import STUDIO_BASE_URL
+
+
+class StudioApiLoginError(Exception):
+ """
+ Error occurred while logging in to the Studio API.
+ """
+ pass
+
+
+class StudioApiFixture(object):
+ """
+ Base class for fixtures that use the Studio restful API.
+ """
+ def __init__(self):
+ # Info about the auto-auth user used to create the course/library.
+ self.user = {}
+
+ @lazy
+ def session(self):
+ """
+ Log in as a staff user, then return a `requests` `session` object for the logged in user.
+ Raises a `StudioApiLoginError` if the login fails.
+ """
+ # Use auto-auth to retrieve the session for a logged in user
+ session = requests.Session()
+ response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true")
+
+ # Return the session from the request
+ if response.ok:
+ # auto_auth returns information about the newly created user
+ # capture this so it can be used by by the testcases.
+ user_pattern = re.compile(r'Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
+ r'(?P\S+)', r'(?P[^\)]+)', r'(?P\S+)', r'(?P\d+)'))
+ user_matches = re.match(user_pattern, response.text)
+ if user_matches:
+ self.user = user_matches.groupdict()
+
+ return session
+
+ else:
+ msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code)
+ raise StudioApiLoginError(msg)
+
+ @lazy
+ def session_cookies(self):
+ """
+ Log in as a staff user, then return the cookies for the session (as a dict)
+ Raises a `StudioApiLoginError` if the login fails.
+ """
+ return {key: val for key, val in self.session.cookies.items()}
+
+ @lazy
+ def headers(self):
+ """
+ Default HTTP headers dict.
+ """
+ return {
+ 'Content-type': 'application/json',
+ 'Accept': 'application/json',
+ 'X-CSRFToken': self.session_cookies.get('csrftoken', '')
+ }
+
+
+class FixtureError(Exception):
+ """
+ Error occurred while installing a course or library fixture.
+ """
+ pass
+
+
+class XBlockContainerFixture(StudioApiFixture):
+ """
+ Base class for course and library fixtures.
+ """
+
+ def __init__(self):
+ self.children = []
+ super(XBlockContainerFixture, self).__init__()
+
+ def add_children(self, *args):
+ """
+ Add children XBlock to the container.
+ Each item in `args` is an `XBlockFixtureDesc` object.
+
+ Returns the fixture to allow chaining.
+ """
+ self.children.extend(args)
+ return self
+
+ def _create_xblock_children(self, parent_loc, xblock_descriptions):
+ """
+ Recursively create XBlock children.
+ """
+ for desc in xblock_descriptions:
+ loc = self.create_xblock(parent_loc, desc)
+ self._create_xblock_children(loc, desc.children)
+
+ def create_xblock(self, parent_loc, xblock_desc):
+ """
+ Create an XBlock with `parent_loc` (the location of the parent block)
+ and `xblock_desc` (an `XBlockFixtureDesc` instance).
+ """
+ create_payload = {
+ 'category': xblock_desc.category,
+ 'display_name': xblock_desc.display_name,
+ }
+
+ if parent_loc is not None:
+ create_payload['parent_locator'] = parent_loc
+
+ # Create the new XBlock
+ response = self.session.post(
+ STUDIO_BASE_URL + '/xblock/',
+ data=json.dumps(create_payload),
+ headers=self.headers,
+ )
+
+ if not response.ok:
+ msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code)
+ raise FixtureError(msg)
+
+ try:
+ loc = response.json().get('locator')
+ xblock_desc.locator = loc
+ except ValueError:
+ raise FixtureError("Could not decode JSON from '{0}'".format(response.content))
+
+ # Configure the XBlock
+ response = self.session.post(
+ STUDIO_BASE_URL + '/xblock/' + loc,
+ data=xblock_desc.serialize(),
+ headers=self.headers,
+ )
+
+ if response.ok:
+ return loc
+ else:
+ raise FixtureError("Could not update {0}. Status code: {1}".format(xblock_desc, response.status_code))
+
+ def _update_xblock(self, locator, data):
+ """
+ Update the xblock at `locator`.
+ """
+ # Create the new XBlock
+ response = self.session.put(
+ "{}/xblock/{}".format(STUDIO_BASE_URL, locator),
+ data=json.dumps(data),
+ headers=self.headers,
+ )
+
+ if not response.ok:
+ msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code)
+ raise FixtureError(msg)
+
+ def _encode_post_dict(self, post_dict):
+ """
+ Encode `post_dict` (a dictionary) as UTF-8 encoded JSON.
+ """
+ return json.dumps({
+ k: v.encode('utf-8') if isinstance(v, basestring) else v
+ for k, v in post_dict.items()
+ })
+
+ def get_nested_xblocks(self, category=None):
+ """
+ Return a list of nested XBlocks for the container that can be filtered by
+ category.
+ """
+ xblocks = self._get_nested_xblocks(self)
+ if category:
+ xblocks = [x for x in xblocks if x.category == category]
+ return xblocks
+
+ def _get_nested_xblocks(self, xblock_descriptor):
+ """
+ Return a list of nested XBlocks for the container.
+ """
+ xblocks = list(xblock_descriptor.children)
+ for child in xblock_descriptor.children:
+ xblocks.extend(self._get_nested_xblocks(child))
+ return xblocks
+
+ def _publish_xblock(self, locator):
+ """
+ Publish the xblock at `locator`.
+ """
+ self._update_xblock(locator, {'publish': 'make_public'})
diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py
index 69836fbee048..1e5bca8a337f 100644
--- a/common/test/acceptance/fixtures/course.py
+++ b/common/test/acceptance/fixtures/course.py
@@ -4,77 +4,17 @@
import mimetypes
import json
-import re
+
import datetime
-import requests
+
from textwrap import dedent
from collections import namedtuple
from path import path
-from lazy import lazy
+
from opaque_keys.edx.keys import CourseKey
from . import STUDIO_BASE_URL
-
-
-class StudioApiLoginError(Exception):
- """
- Error occurred while logging in to the Studio API.
- """
- pass
-
-
-class StudioApiFixture(object):
- """
- Base class for fixtures that use the Studio restful API.
- """
- def __init__(self):
- # Info about the auto-auth user used to create the course.
- self.user = {}
-
- @lazy
- def session(self):
- """
- Log in as a staff user, then return a `requests` `session` object for the logged in user.
- Raises a `StudioApiLoginError` if the login fails.
- """
- # Use auto-auth to retrieve the session for a logged in user
- session = requests.Session()
- response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true")
-
- # Return the session from the request
- if response.ok:
- # auto_auth returns information about the newly created user
- # capture this so it can be used by by the testcases.
- user_pattern = re.compile('Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
- '(?P\S+)', '(?P[^\)]+)', '(?P\S+)', '(?P\d+)'))
- user_matches = re.match(user_pattern, response.text)
- if user_matches:
- self.user = user_matches.groupdict()
-
- return session
-
- else:
- msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code)
- raise StudioApiLoginError(msg)
-
- @lazy
- def session_cookies(self):
- """
- Log in as a staff user, then return the cookies for the session (as a dict)
- Raises a `StudioApiLoginError` if the login fails.
- """
- return {key: val for key, val in self.session.cookies.items()}
-
- @lazy
- def headers(self):
- """
- Default HTTP headers dict.
- """
- return {
- 'Content-type': 'application/json',
- 'Accept': 'application/json',
- 'X-CSRFToken': self.session_cookies.get('csrftoken', '')
- }
+from .base import XBlockContainerFixture, FixtureError
class XBlockFixtureDesc(object):
@@ -105,7 +45,7 @@ def __init__(self, category, display_name, data=None, metadata=None, grader_type
def add_children(self, *args):
"""
Add child XBlocks to this XBlock.
- Each item in `args` is an `XBlockFixtureDescriptor` object.
+ Each item in `args` is an `XBlockFixtureDesc` object.
Returns the `xblock_desc` instance to allow chaining.
"""
@@ -154,14 +94,7 @@ def __str__(self):
CourseUpdateDesc = namedtuple("CourseUpdateDesc", ['date', 'content'])
-class CourseFixtureError(Exception):
- """
- Error occurred while installing a course fixture.
- """
- pass
-
-
-class CourseFixture(StudioApiFixture):
+class CourseFixture(XBlockContainerFixture):
"""
Fixture for ensuring that a course exists.
@@ -181,6 +114,7 @@ def __init__(self, org, number, run, display_name, start_date=None, end_date=Non
These have the same meaning as in the Studio restful API /course end-point.
"""
+ super(CourseFixture, self).__init__()
self._course_dict = {
'org': org,
'number': number,
@@ -202,7 +136,6 @@ def __init__(self, org, number, run, display_name, start_date=None, end_date=Non
self._updates = []
self._handouts = []
- self.children = []
self._assets = []
self._advanced_settings = {}
self._course_key = None
@@ -213,16 +146,6 @@ def __str__(self):
"""
return "".format(**self._course_dict)
- def add_children(self, *args):
- """
- Add children XBlock to the course.
- Each item in `args` is an `XBlockFixtureDescriptor` object.
-
- Returns the course fixture to allow chaining.
- """
- self.children.extend(args)
- return self
-
def add_update(self, update):
"""
Add an update to the course. `update` should be a `CourseUpdateDesc`.
@@ -252,7 +175,7 @@ def install(self):
"""
Create the course and XBlocks within the course.
This is NOT an idempotent method; if the course already exists, this will
- raise a `CourseFixtureError`. You should use unique course identifiers to avoid
+ raise a `FixtureError`. You should use unique course identifiers to avoid
conflicts between tests.
"""
self._create_course()
@@ -308,18 +231,18 @@ def _create_course(self):
err = response.json().get('ErrMsg')
except ValueError:
- raise CourseFixtureError(
+ raise FixtureError(
"Could not parse response from course request as JSON: '{0}'".format(
response.content))
# This will occur if the course identifier is not unique
if err is not None:
- raise CourseFixtureError("Could not create course {0}. Error message: '{1}'".format(self, err))
+ raise FixtureError("Could not create course {0}. Error message: '{1}'".format(self, err))
if response.ok:
self._course_key = response.json()['course_key']
else:
- raise CourseFixtureError(
+ raise FixtureError(
"Could not create course {0}. Status was {1}".format(
self._course_dict, response.status_code))
@@ -333,14 +256,14 @@ def _configure_course(self):
response = self.session.get(url, headers=self.headers)
if not response.ok:
- raise CourseFixtureError(
+ raise FixtureError(
"Could not retrieve course details. Status was {0}".format(
response.status_code))
try:
details = response.json()
except ValueError:
- raise CourseFixtureError(
+ raise FixtureError(
"Could not decode course details as JSON: '{0}'".format(details)
)
@@ -354,7 +277,7 @@ def _configure_course(self):
)
if not response.ok:
- raise CourseFixtureError(
+ raise FixtureError(
"Could not update course details to '{0}' with {1}: Status was {2}.".format(
self._course_details, url, response.status_code))
@@ -382,7 +305,7 @@ def _install_course_handouts(self):
response = self.session.post(url, data=payload, headers=self.headers)
if not response.ok:
- raise CourseFixtureError(
+ raise FixtureError(
"Could not update course handouts with {0}. Status was {1}".format(url, response.status_code))
def _install_course_updates(self):
@@ -399,14 +322,14 @@ def _install_course_updates(self):
response = self.session.post(url, headers=self.headers, data=payload)
if not response.ok:
- raise CourseFixtureError(
+ raise FixtureError(
"Could not add update to course: {0} with {1}. Status was {2}".format(
update, url, response.status_code))
def _upload_assets(self):
"""
Upload assets
- :raise CourseFixtureError:
+ :raise FixtureError:
"""
url = STUDIO_BASE_URL + self._assets_url
@@ -426,7 +349,7 @@ def _upload_assets(self):
upload_response = self.session.post(url, files=files, headers=headers)
if not upload_response.ok:
- raise CourseFixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format(
+ raise FixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format(
asset_name=asset_name, url=url, code=upload_response.status_code))
def _add_advanced_settings(self):
@@ -442,7 +365,7 @@ def _add_advanced_settings(self):
)
if not response.ok:
- raise CourseFixtureError(
+ raise FixtureError(
"Could not update advanced details to '{0}' with {1}: Status was {2}.".format(
self._advanced_settings, url, response.status_code))
@@ -450,101 +373,7 @@ def _create_xblock_children(self, parent_loc, xblock_descriptions):
"""
Recursively create XBlock children.
"""
- for desc in xblock_descriptions:
- loc = self.create_xblock(parent_loc, desc)
- self._create_xblock_children(loc, desc.children)
-
+ super(CourseFixture, self)._create_xblock_children(parent_loc, xblock_descriptions)
self._publish_xblock(parent_loc)
- def get_nested_xblocks(self, category=None):
- """
- Return a list of nested XBlocks for the course that can be filtered by
- category.
- """
- xblocks = self._get_nested_xblocks(self)
- if category:
- xblocks = filter(lambda x: x.category == category, xblocks)
- return xblocks
-
- def _get_nested_xblocks(self, xblock_descriptor):
- """
- Return a list of nested XBlocks for the course.
- """
- xblocks = list(xblock_descriptor.children)
- for child in xblock_descriptor.children:
- xblocks.extend(self._get_nested_xblocks(child))
- return xblocks
-
- def create_xblock(self, parent_loc, xblock_desc):
- """
- Create an XBlock with `parent_loc` (the location of the parent block)
- and `xblock_desc` (an `XBlockFixtureDesc` instance).
- """
- create_payload = {
- 'category': xblock_desc.category,
- 'display_name': xblock_desc.display_name,
- }
-
- if parent_loc is not None:
- create_payload['parent_locator'] = parent_loc
-
- # Create the new XBlock
- response = self.session.post(
- STUDIO_BASE_URL + '/xblock/',
- data=json.dumps(create_payload),
- headers=self.headers,
- )
-
- if not response.ok:
- msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code)
- raise CourseFixtureError(msg)
- try:
- loc = response.json().get('locator')
- xblock_desc.locator = loc
- except ValueError:
- raise CourseFixtureError("Could not decode JSON from '{0}'".format(response.content))
-
- # Configure the XBlock
- response = self.session.post(
- STUDIO_BASE_URL + '/xblock/' + loc,
- data=xblock_desc.serialize(),
- headers=self.headers,
- )
-
- if response.ok:
- return loc
- else:
- raise CourseFixtureError(
- "Could not update {0}. Status code: {1}".format(
- xblock_desc, response.status_code))
-
- def _publish_xblock(self, locator):
- """
- Publish the xblock at `locator`.
- """
- self._update_xblock(locator, {'publish': 'make_public'})
-
- def _update_xblock(self, locator, data):
- """
- Update the xblock at `locator`.
- """
- # Create the new XBlock
- response = self.session.put(
- "{}/xblock/{}".format(STUDIO_BASE_URL, locator),
- data=json.dumps(data),
- headers=self.headers,
- )
-
- if not response.ok:
- msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code)
- raise CourseFixtureError(msg)
-
- def _encode_post_dict(self, post_dict):
- """
- Encode `post_dict` (a dictionary) as UTF-8 encoded JSON.
- """
- return json.dumps({
- k: v.encode('utf-8') if isinstance(v, basestring) else v
- for k, v in post_dict.items()
- })
diff --git a/common/test/acceptance/fixtures/library.py b/common/test/acceptance/fixtures/library.py
new file mode 100644
index 000000000000..f97b8e9fc222
--- /dev/null
+++ b/common/test/acceptance/fixtures/library.py
@@ -0,0 +1,92 @@
+"""
+Fixture to create a Content Library
+"""
+
+from opaque_keys.edx.keys import CourseKey
+
+from . import STUDIO_BASE_URL
+from .base import XBlockContainerFixture, FixtureError
+
+
+class LibraryFixture(XBlockContainerFixture):
+ """
+ Fixture for ensuring that a library exists.
+
+ WARNING: This fixture is NOT idempotent. To avoid conflicts
+ between tests, you should use unique library identifiers for each fixture.
+ """
+
+ def __init__(self, org, number, display_name):
+ """
+ Configure the library fixture to create a library with
+ """
+ super(LibraryFixture, self).__init__()
+ self.library_info = {
+ 'org': org,
+ 'number': number,
+ 'display_name': display_name
+ }
+
+ self._library_key = None
+ super(LibraryFixture, self).__init__()
+
+ def __str__(self):
+ """
+ String representation of the library fixture, useful for debugging.
+ """
+ return "".format(**self.library_info)
+
+ def install(self):
+ """
+ Create the library and XBlocks within the library.
+ This is NOT an idempotent method; if the library already exists, this will
+ raise a `FixtureError`. You should use unique library identifiers to avoid
+ conflicts between tests.
+ """
+ self._create_library()
+ self._create_xblock_children(self.library_location, self.children)
+
+ return self
+
+ @property
+ def library_key(self):
+ """
+ Get the LibraryLocator for this library, as a string.
+ """
+ return self._library_key
+
+ @property
+ def library_location(self):
+ """
+ Return the locator string for the LibraryRoot XBlock that is the root of the library hierarchy.
+ """
+ lib_key = CourseKey.from_string(self._library_key)
+ return unicode(lib_key.make_usage_key('library', 'library'))
+
+ def _create_library(self):
+ """
+ Create the library described in the fixture.
+ Will fail if the library already exists.
+ """
+ response = self.session.post(
+ STUDIO_BASE_URL + '/library/',
+ data=self._encode_post_dict(self.library_info),
+ headers=self.headers
+ )
+
+ if response.ok:
+ self._library_key = response.json()['library_key']
+ else:
+ try:
+ err_msg = response.json().get('ErrMsg')
+ except ValueError:
+ err_msg = "Unknown Error"
+ raise FixtureError(
+ "Could not create library {}. Status was {}, error was: {}".format(self.library_info, response.status_code, err_msg)
+ )
+
+ def create_xblock(self, parent_loc, xblock_desc):
+ # Disable publishing for library XBlocks:
+ xblock_desc.publish = "not-applicable"
+
+ return super(LibraryFixture, self).create_xblock(parent_loc, xblock_desc)
diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py
index e65d55146f0c..20cf140ed117 100644
--- a/common/test/acceptance/pages/studio/container.py
+++ b/common/test/acceptance/pages/studio/container.py
@@ -6,7 +6,7 @@
from bok_choy.promise import Promise, EmptyPromise
from . import BASE_URL
-from utils import click_css, confirm_prompt
+from .utils import click_css, confirm_prompt, type_in_codemirror
class ContainerPage(PageObject):
@@ -362,6 +362,12 @@ def open_basic_tab(self):
"""
self._click_button('basic_tab')
+ def set_codemirror_text(self, text, index=0):
+ """
+ Set the text of a CodeMirror editor that is part of this xblock's settings.
+ """
+ type_in_codemirror(self, index, text, find_prefix='$("{}").find'.format(self.editor_selector))
+
def save_settings(self):
"""
Click on settings Save button.
diff --git a/common/test/acceptance/pages/studio/index.py b/common/test/acceptance/pages/studio/index.py
index af163eca6852..aed9a5faae23 100644
--- a/common/test/acceptance/pages/studio/index.py
+++ b/common/test/acceptance/pages/studio/index.py
@@ -28,6 +28,13 @@ def course_runs(self):
def has_processing_courses(self):
return self.q(css='.courses-processing').present
+ @property
+ def page_subheader(self):
+ """
+ Get the text of the introductory copy seen below the Welcome header. ("Here are all of...")
+ """
+ return self.q(css='.content-primary .introduction .copy p').first.text[0]
+
def create_rerun(self, display_name):
"""
Clicks the create rerun link of the course specified by display_name.
@@ -40,3 +47,68 @@ def click_course_run(self, run):
Clicks on the course with run given by run.
"""
self.q(css='.course-run .value').filter(lambda el: el.text == run)[0].click()
+
+ def has_new_library_button(self):
+ """
+ (bool) is the "New Library" button present?
+ """
+ return self.q(css='.new-library-button').present
+
+ def click_new_library(self):
+ """
+ Click on the "New Library" button
+ """
+ self.q(css='.new-library-button').click()
+
+ def is_new_library_form_visible(self):
+ """
+ Is the new library form visisble?
+ """
+ return self.q(css='.wrapper-create-library').visible
+
+ def fill_new_library_form(self, display_name, org, number):
+ """
+ Fill out the form to create a new library.
+ Must have called click_new_library() first.
+ """
+ field = lambda fn: self.q(css='.wrapper-create-library #new-library-{}'.format(fn))
+ field('name').fill(display_name)
+ field('org').fill(org)
+ field('number').fill(number)
+
+ def is_new_library_form_valid(self):
+ """
+ IS the new library form ready to submit?
+ """
+ return (
+ self.q(css='.wrapper-create-library .new-library-save:not(.is-disabled)').present and
+ not self.q(css='.wrapper-create-library .wrap-error.is-shown').present
+ )
+
+ def submit_new_library_form(self):
+ """
+ Submit the new library form.
+ """
+ self.q(css='.wrapper-create-library .new-library-save').click()
+
+ def list_libraries(self):
+ """
+ List all the libraries found on the page's list of libraries.
+ """
+ self.q(css='#course-index-tabs .libraries-tab a').click() # Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements
+ div2info = lambda element: {
+ 'name': element.find_element_by_css_selector('.course-title').text,
+ 'org': element.find_element_by_css_selector('.course-org .value').text,
+ 'number': element.find_element_by_css_selector('.course-num .value').text,
+ 'url': element.find_element_by_css_selector('a.library-link').get_attribute('href'),
+ }
+ return self.q(css='.libraries li.course-item').map(div2info).results
+
+ def has_library(self, **kwargs):
+ """
+ Does the page's list of libraries include a library matching kwargs?
+ """
+ for lib in self.list_libraries():
+ if all([lib[key] == kwargs[key] for key in kwargs]):
+ return True
+ return False
diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py
new file mode 100644
index 000000000000..e87c556da968
--- /dev/null
+++ b/common/test/acceptance/pages/studio/library.py
@@ -0,0 +1,97 @@
+"""
+Library edit page in Studio
+"""
+
+from bok_choy.page_object import PageObject
+from .container import XBlockWrapper
+from ...tests.helpers import disable_animations
+from .utils import confirm_prompt, wait_for_notification
+from . import BASE_URL
+
+
+class LibraryPage(PageObject):
+ """
+ Library page in Studio
+ """
+
+ def __init__(self, browser, locator):
+ super(LibraryPage, self).__init__(browser)
+ self.locator = locator
+
+ @property
+ def url(self):
+ """
+ URL to the library edit page for the given library.
+ """
+ return "{}/library/{}".format(BASE_URL, unicode(self.locator))
+
+ def is_browser_on_page(self):
+ """
+ Returns True iff the browser has loaded the library edit page.
+ """
+ return self.q(css='body.view-library').present
+
+ def get_header_title(self):
+ """
+ The text of the main heading (H1) visible on the page.
+ """
+ return self.q(css='h1.page-header-title').text
+
+ def wait_until_ready(self):
+ """
+ When the page first loads, there is a loading indicator and most
+ functionality is not yet available. This waits for that loading to
+ finish.
+
+ Always call this before using the page. It also disables animations
+ for improved test reliability.
+ """
+ self.wait_for_ajax()
+ self.wait_for_element_invisibility('.ui-loading', 'Wait for the page to complete its initial loading of XBlocks via AJAX')
+ disable_animations(self)
+
+ @property
+ def xblocks(self):
+ """
+ Return a list of xblocks loaded on the container page.
+ """
+ return self._get_xblocks()
+
+ def click_duplicate_button(self, xblock_id):
+ """
+ Click on the duplicate button for the given XBlock
+ """
+ self._action_btn_for_xblock_id(xblock_id, "duplicate").click()
+ wait_for_notification(self)
+ self.wait_for_ajax()
+
+ def click_delete_button(self, xblock_id, confirm=True):
+ """
+ Click on the delete button for the given XBlock
+ """
+ self._action_btn_for_xblock_id(xblock_id, "delete").click()
+ if confirm:
+ confirm_prompt(self) # this will also wait_for_notification()
+ self.wait_for_ajax()
+
+ def _get_xblocks(self):
+ """
+ Create an XBlockWrapper for each XBlock div found on the page.
+ """
+ prefix = '.wrapper-xblock.level-page '
+ return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map(lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
+
+ def _div_for_xblock_id(self, xblock_id):
+ """
+ Given an XBlock's usage locator as a string, return the WebElement for
+ that block's wrapper div.
+ """
+ return self.q(css='.wrapper-xblock.level-page .studio-xblock-wrapper').filter(lambda el: el.get_attribute('data-locator') == xblock_id)
+
+ def _action_btn_for_xblock_id(self, xblock_id, action):
+ """
+ Given an XBlock's usage locator as a string, return one of its action
+ buttons.
+ action is 'edit', 'duplicate', or 'delete'
+ """
+ return self._div_for_xblock_id(xblock_id)[0].find_element_by_css_selector('.header-actions .{action}-button.action-button'.format(action=action))
diff --git a/common/test/acceptance/pages/studio/utils.py b/common/test/acceptance/pages/studio/utils.py
index a94f50ba6fa1..dd8ec091a347 100644
--- a/common/test/acceptance/pages/studio/utils.py
+++ b/common/test/acceptance/pages/studio/utils.py
@@ -103,6 +103,30 @@ def add_advanced_component(page, menu_index, name):
click_css(page, component_css, 0)
+def add_component(page, item_type, specific_type):
+ """
+ Click one of the "Add New Component" buttons.
+
+ item_type should be "advanced", "html", "problem", or "video"
+
+ specific_type is required for some types and should be something like
+ "Blank Common Problem".
+ """
+ btn = page.q(css='.add-xblock-component .add-xblock-component-button[data-type={}]'.format(item_type))
+ multiple_templates = btn.filter(lambda el: 'multiple-templates' in el.get_attribute('class')).present
+ btn.click()
+ if multiple_templates:
+ sub_template_menu_div_selector = '.new-component-{}'.format(item_type)
+ page.wait_for_element_visibility(sub_template_menu_div_selector, 'Wait for the templates sub-menu to appear')
+ page.wait_for_element_invisibility('.add-xblock-component .new-component', 'Wait for the add component menu to disappear')
+
+ all_options = page.q(css='.new-component-{} ul.new-component-template li a span'.format(item_type))
+ chosen_option = all_options.filter(lambda el: el.text == specific_type).first
+ chosen_option.click()
+ wait_for_notification(page)
+ page.wait_for_ajax()
+
+
@js_defined('window.jQuery')
def type_in_codemirror(page, index, text, find_prefix="$"):
script = """
diff --git a/common/test/acceptance/tests/studio/base_studio_test.py b/common/test/acceptance/tests/studio/base_studio_test.py
index fa07533fba86..ec94f7f058c5 100644
--- a/common/test/acceptance/tests/studio/base_studio_test.py
+++ b/common/test/acceptance/tests/studio/base_studio_test.py
@@ -1,5 +1,10 @@
+"""
+Base classes used by studio tests.
+"""
+from bok_choy.web_app_test import WebAppTest
from ...pages.studio.auto_auth import AutoAuthPage
from ...fixtures.course import CourseFixture
+from ...fixtures.library import LibraryFixture
from ..helpers import UniqueCourseTest
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.utils import verify_ordering
@@ -98,3 +103,46 @@ def do_action_and_verify(self, action, expected_ordering):
# Reload the page to see that the change was persisted.
container = self.go_to_nested_container_page()
verify_ordering(self, container, expected_ordering)
+
+
+class StudioLibraryTest(WebAppTest):
+ """
+ Base class for all Studio library tests.
+ """
+
+ def setUp(self, is_staff=False): # pylint: disable=arguments-differ
+ """
+ Install a library with no content using a fixture.
+ """
+ super(StudioLibraryTest, self).setUp()
+ fixture = LibraryFixture(
+ 'test_org',
+ self.unique_id,
+ 'Test Library {}'.format(self.unique_id),
+ )
+ self.populate_library_fixture(fixture)
+ fixture.install()
+ self.library_info = fixture.library_info
+ self.library_key = fixture.library_key
+ self.user = fixture.user
+ self.log_in(self.user, is_staff)
+
+ def populate_library_fixture(self, library_fixture):
+ """
+ Populate the children of the test course fixture.
+ """
+ pass
+
+ def log_in(self, user, is_staff=False):
+ """
+ Log in as the user that created the library.
+ By default the user will not have staff access unless is_staff is passed as True.
+ """
+ auth_page = AutoAuthPage(
+ self.browser,
+ staff=is_staff,
+ username=user.get('username'),
+ email=user.get('email'),
+ password=user.get('password')
+ )
+ auth_page.visit()
diff --git a/common/test/acceptance/tests/studio/test_studio_home.py b/common/test/acceptance/tests/studio/test_studio_home.py
new file mode 100644
index 000000000000..9dc9b0249716
--- /dev/null
+++ b/common/test/acceptance/tests/studio/test_studio_home.py
@@ -0,0 +1,67 @@
+"""
+Acceptance tests for Home Page (My Courses / My Libraries).
+"""
+from bok_choy.web_app_test import WebAppTest
+from opaque_keys.edx.locator import LibraryLocator
+
+from ...pages.studio.auto_auth import AutoAuthPage
+from ...pages.studio.library import LibraryPage
+from ...pages.studio.index import DashboardPage
+
+
+class CreateLibraryTest(WebAppTest):
+ """
+ Test that we can create a new content library on the studio home page.
+ """
+
+ def setUp(self):
+ """
+ Load the helper for the home page (dashboard page)
+ """
+ super(CreateLibraryTest, self).setUp()
+
+ self.auth_page = AutoAuthPage(self.browser, staff=True)
+ self.dashboard_page = DashboardPage(self.browser)
+
+ def test_subheader(self):
+ """
+ From the home page:
+ Verify that subheader is correct
+ """
+ self.auth_page.visit()
+ self.dashboard_page.visit()
+
+ self.assertIn("courses and libraries", self.dashboard_page.page_subheader)
+
+ def test_create_library(self):
+ """
+ From the home page:
+ Click "New Library"
+ Fill out the form
+ Submit the form
+ We should be redirected to the edit view for the library
+ Return to the home page
+ The newly created library should now appear in the list of libraries
+ """
+ name = "New Library Name"
+ org = "TestOrgX"
+ number = "TESTLIB"
+
+ self.auth_page.visit()
+ self.dashboard_page.visit()
+ self.assertFalse(self.dashboard_page.has_library(name=name, org=org, number=number))
+ self.assertTrue(self.dashboard_page.has_new_library_button())
+
+ self.dashboard_page.click_new_library()
+ self.assertTrue(self.dashboard_page.is_new_library_form_visible())
+ self.dashboard_page.fill_new_library_form(name, org, number)
+ self.assertTrue(self.dashboard_page.is_new_library_form_valid())
+ self.dashboard_page.submit_new_library_form()
+
+ # The next page is the library edit view; make sure it loads:
+ lib_page = LibraryPage(self.browser, LibraryLocator(org, number))
+ lib_page.wait_for_page()
+
+ # Then go back to the home page and make sure the new library is listed there:
+ self.dashboard_page.visit()
+ self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number))
diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py
new file mode 100644
index 000000000000..d5ad890e376c
--- /dev/null
+++ b/common/test/acceptance/tests/studio/test_studio_library.py
@@ -0,0 +1,104 @@
+"""
+Acceptance tests for Content Libraries in Studio
+"""
+
+from .base_studio_test import StudioLibraryTest
+from ...pages.studio.utils import add_component
+from ...pages.studio.library import LibraryPage
+
+
+class LibraryEditPageTest(StudioLibraryTest):
+ """
+ Test the functionality of the library edit page.
+ """
+ def setUp(self): # pylint: disable=arguments-differ
+ """
+ Ensure a library exists and navigate to the library edit page.
+ """
+ super(LibraryEditPageTest, self).setUp(is_staff=True)
+ self.lib_page = LibraryPage(self.browser, self.library_key)
+ self.lib_page.visit()
+ self.lib_page.wait_until_ready()
+
+ def test_page_header(self):
+ """
+ Scenario: Ensure that the library's name is displayed in the header and title.
+ Given I have a library in Studio
+ And I navigate to Library Page in Studio
+ Then I can see library name in page header title
+ And I can see library name in browser page title
+ """
+ self.assertIn(self.library_info['display_name'], self.lib_page.get_header_title())
+ self.assertIn(self.library_info['display_name'], self.browser.title)
+
+ def test_add_duplicate_delete_actions(self):
+ """
+ Scenario: Ensure that we can add an HTML block, duplicate it, then delete the original.
+ Given I have a library in Studio with no XBlocks
+ And I navigate to Library Page in Studio
+ Then there are no XBlocks displayed
+ When I add Text XBlock
+ Then one XBlock is displayed
+ When I duplicate first XBlock
+ Then two XBlocks are displayed
+ And those XBlocks locators' are different
+ When I delete first XBlock
+ Then one XBlock is displayed
+ And displayed XBlock are second one
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 0)
+
+ # Create a new block:
+ add_component(self.lib_page, "html", "Text")
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ first_block_id = self.lib_page.xblocks[0].locator
+
+ # Duplicate the block:
+ self.lib_page.click_duplicate_button(first_block_id)
+ self.assertEqual(len(self.lib_page.xblocks), 2)
+ second_block_id = self.lib_page.xblocks[1].locator
+ self.assertNotEqual(first_block_id, second_block_id)
+
+ # Delete the first block:
+ self.lib_page.click_delete_button(first_block_id, confirm=True)
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ self.assertEqual(self.lib_page.xblocks[0].locator, second_block_id)
+
+ def test_add_edit_xblock(self):
+ """
+ Scenario: Ensure that we can add an XBlock, edit it, then see the resulting changes.
+ Given I have a library in Studio with no XBlocks
+ And I navigate to Library Page in Studio
+ Then there are no XBlocks displayed
+ When I add Multiple Choice XBlock
+ Then one XBlock is displayed
+ When I edit first XBlock
+ And I go to basic tab
+ And set it's text to a fairly trivial question about Battlestar Galactica
+ And save XBlock
+ Then one XBlock is displayed
+ And first XBlock student content contains at least part of text I set
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 0)
+ # Create a new problem block:
+ add_component(self.lib_page, "problem", "Multiple Choice")
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ problem_block = self.lib_page.xblocks[0]
+ # Edit it:
+ problem_block.edit()
+ problem_block.open_basic_tab()
+ problem_block.set_codemirror_text(
+ """
+ >>Who is "Starbuck"?<<
+ (x) Kara Thrace
+ ( ) William Adama
+ ( ) Laura Roslin
+ ( ) Lee Adama
+ ( ) Gaius Baltar
+ """
+ )
+ problem_block.save_settings()
+ # Check that the save worked:
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ problem_block = self.lib_page.xblocks[0]
+ self.assertIn("Laura Roslin", problem_block.student_content)
From 6963e8eb3d261a5b6d94897002e9abeb9cf17b54 Mon Sep 17 00:00:00 2001
From: Jonathan Piacenti
Date: Tue, 2 Dec 2014 17:58:34 +0000
Subject: [PATCH 02/23] Removed the ability to add Discussion and advanced
components to Content Libraries.
---
.../contentstore/views/component.py | 20 ++++++++---
cms/djangoapps/contentstore/views/item.py | 7 ++++
cms/djangoapps/contentstore/views/library.py | 2 +-
.../contentstore/views/tests/test_item.py | 35 +++++++++++++++++++
.../contentstore/views/tests/test_library.py | 14 ++++++++
.../tests/studio/test_studio_library.py | 7 +++-
6 files changed, 78 insertions(+), 7 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 9768542ea8b8..90f1dde26723 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -217,9 +217,9 @@ def container_handler(request, usage_key_string):
return HttpResponseBadRequest("Only supports HTML requests")
-def get_component_templates(course):
+def get_component_templates(courselike, library=False):
"""
- Returns the applicable component templates that can be used by the specified course.
+ Returns the applicable component templates that can be used by the specified course or library.
"""
def create_template_dict(name, cat, boilerplate_name=None, is_common=False):
"""
@@ -250,7 +250,13 @@ def create_template_dict(name, cat, boilerplate_name=None, is_common=False):
categories = set()
# The component_templates array is in the order of "advanced" (if present), followed
# by the components in the order listed in COMPONENT_TYPES.
- for category in COMPONENT_TYPES:
+ component_types = COMPONENT_TYPES[:]
+
+ # Libraries do not support discussions
+ if library:
+ component_types = [component for component in component_types if component != 'discussion']
+
+ for category in component_types:
templates_for_category = []
component_class = _load_mixed_class(category)
# add the default template with localized display name
@@ -264,7 +270,7 @@ def create_template_dict(name, cat, boilerplate_name=None, is_common=False):
if hasattr(component_class, 'templates'):
for template in component_class.templates():
filter_templates = getattr(component_class, 'filter_templates', None)
- if not filter_templates or filter_templates(template, course):
+ if not filter_templates or filter_templates(template, courselike):
templates_for_category.append(
create_template_dict(
_(template['metadata'].get('display_name')),
@@ -289,11 +295,15 @@ def create_template_dict(name, cat, boilerplate_name=None, is_common=False):
"display_name": component_display_names[category]
})
+ # Libraries do not support advanced components at this time.
+ if library:
+ return component_templates
+
# Check if there are any advanced modules specified in the course policy.
# These modules should be specified as a list of strings, where the strings
# are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
# enabled for the course.
- course_advanced_keys = course.advanced_modules
+ course_advanced_keys = courselike.advanced_modules
advanced_component_templates = {"type": "advanced", "templates": [], "display_name": _("Advanced")}
advanced_component_types = _advanced_component_types()
# Set component types according to course policy file
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 815b67795157..d3123aa0d04d 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -461,6 +461,13 @@ def _create_item(request):
if not has_course_author_access(request.user, usage_key.course_key):
raise PermissionDenied()
+ if isinstance(usage_key, LibraryUsageLocator):
+ # Only these categories are supported at this time.
+ if category not in ['html', 'problem', 'video']:
+ return HttpResponseBadRequest(
+ "Category '%s' not supported for Libraries" % category, content_type='text/plain'
+ )
+
store = modulestore()
with store.bulk_operations(usage_key.course_key):
parent = store.get_item(usage_key)
diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py
index 15e54a37ce35..1fdc8381a8f4 100644
--- a/cms/djangoapps/contentstore/views/library.py
+++ b/cms/djangoapps/contentstore/views/library.py
@@ -175,7 +175,7 @@ def library_blocks_view(library, response_format):
})
xblock_info = create_xblock_info(library, include_ancestor_info=False, graders=[])
- component_templates = get_component_templates(library)
+ component_templates = get_component_templates(library, library=True)
return render_to_response('library.html', {
'context_library': library,
diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py
index 3f4fa86cba0c..d6d913a59489 100644
--- a/cms/djangoapps/contentstore/views/tests/test_item.py
+++ b/cms/djangoapps/contentstore/views/tests/test_item.py
@@ -1469,6 +1469,41 @@ def validate_component_xblock_info(self, xblock_info, original_block):
self.assertIsNone(xblock_info.get('graders', None))
+class TestLibraryXBlockCreation(ItemTest):
+ """
+ Tests the adding of XBlocks to Library
+ """
+ def test_add_xblock(self):
+ """
+ Verify we can add an XBlock to a Library.
+ """
+ lib = LibraryFactory.create()
+ self.create_xblock(parent_usage_key=lib.location, display_name='Test', category="html")
+ lib = self.store.get_library(lib.location.library_key)
+ self.assertTrue(lib.children)
+ xblock_locator = lib.children[0]
+ self.assertEqual(self.store.get_item(xblock_locator).display_name, 'Test')
+
+ def test_no_add_discussion(self):
+ """
+ Verify we cannot add a discussion module to a Library.
+ """
+ lib = LibraryFactory.create()
+ response = self.create_xblock(parent_usage_key=lib.location, display_name='Test', category='discussion')
+ self.assertEqual(response.status_code, 400)
+ lib = self.store.get_library(lib.location.library_key)
+ self.assertFalse(lib.children)
+
+ def test_no_add_advanced(self):
+ lib = LibraryFactory.create()
+ lib.advanced_modules = ['lti']
+ lib.save()
+ response = self.create_xblock(parent_usage_key=lib.location, display_name='Test', category='lti')
+ self.assertEqual(response.status_code, 400)
+ lib = self.store.get_library(lib.location.library_key)
+ self.assertFalse(lib.children)
+
+
class TestXBlockPublishingInfo(ItemTest):
"""
Unit tests for XBlock's outline handling.
diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py
index 8cae9710873f..9ab5bc06ccf0 100644
--- a/cms/djangoapps/contentstore/views/tests/test_library.py
+++ b/cms/djangoapps/contentstore/views/tests/test_library.py
@@ -4,6 +4,7 @@
More important high-level tests are in contentstore/tests/test_libraries.py
"""
from contentstore.tests.utils import AjaxEnabledTestClient, parse_json
+from contentstore.views.component import get_component_templates
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import LibraryFactory
from mock import patch
@@ -183,3 +184,16 @@ def test_no_access(self):
lib = LibraryFactory.create()
response = self.client.get(make_url_for_lib(lib.location.library_key))
self.assertEqual(response.status_code, 403)
+
+ def test_get_component_templates(self):
+ """
+ Verify that templates for adding discussion and advanced components to
+ content libraries are not provided.
+ """
+ lib = LibraryFactory.create()
+ lib.advanced_modules = ['lti']
+ lib.save()
+ templates = [template['type'] for template in get_component_templates(lib, library=True)]
+ self.assertIn('problem', templates)
+ self.assertNotIn('discussion', templates)
+ self.assertNotIn('advanced', templates)
diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py
index d5ad890e376c..b505ac140dbc 100644
--- a/common/test/acceptance/tests/studio/test_studio_library.py
+++ b/common/test/acceptance/tests/studio/test_studio_library.py
@@ -1,7 +1,6 @@
"""
Acceptance tests for Content Libraries in Studio
"""
-
from .base_studio_test import StudioLibraryTest
from ...pages.studio.utils import add_component
from ...pages.studio.library import LibraryPage
@@ -102,3 +101,9 @@ def test_add_edit_xblock(self):
self.assertEqual(len(self.lib_page.xblocks), 1)
problem_block = self.lib_page.xblocks[0]
self.assertIn("Laura Roslin", problem_block.student_content)
+
+ def test_no_discussion_button(self):
+ """
+ Ensure the UI is not loaded for adding discussions.
+ """
+ self.assertFalse(self.browser.find_elements_by_css_selector('span.large-discussion-icon'))
From 459b65143e2f3d06d5b9c9cdb41a13af003c0acd Mon Sep 17 00:00:00 2001
From: "E. Kolpakov"
Date: Mon, 3 Nov 2014 20:20:29 +0700
Subject: [PATCH 03/23] Paging for LibraryView added with JS tests.
---
cms/djangoapps/contentstore/views/item.py | 18 +-
cms/static/coffee/spec/main.coffee | 1 +
cms/static/js/factories/container.js | 22 +-
cms/static/js/factories/library.js | 22 +-
.../js/spec/views/library_container_spec.js | 489 ++++++++++
.../js/spec/views/pages/container_spec.js | 893 +++++++++---------
cms/static/js/views/container.js | 4 +
cms/static/js/views/library_container.js | 164 ++++
cms/static/js/views/pages/container.js | 52 +-
cms/static/js/views/paging_footer.js | 6 +
cms/static/sass/elements/_pagination.scss | 119 +++
cms/static/sass/elements/_xblocks.scss | 31 +
cms/static/sass/style-app-extend1-rtl.scss | 1 +
cms/static/sass/style-app-extend1.scss | 1 +
cms/static/sass/views/_assets.scss | 122 +--
cms/templates/container.html | 5 +-
...ontainer-paged-after-add-xblock.underscore | 283 ++++++
.../mock-container-paged-xblock.underscore | 257 +++++
cms/templates/library.html | 8 +-
.../xmodule/xmodule/library_root_xblock.py | 54 +-
.../xmodule/video_module/video_handlers.py | 1 -
.../studio_render_paged_children_view.html | 23 +
22 files changed, 1963 insertions(+), 613 deletions(-)
create mode 100644 cms/static/js/spec/views/library_container_spec.js
create mode 100644 cms/static/js/views/library_container.js
create mode 100644 cms/static/sass/elements/_pagination.scss
create mode 100644 cms/templates/js/mock/mock-container-paged-after-add-xblock.underscore
create mode 100644 cms/templates/js/mock/mock-container-paged-xblock.underscore
create mode 100644 lms/templates/studio_render_paged_children_view.html
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index d3123aa0d04d..5d05a27e1781 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -238,12 +238,28 @@ def xblock_view_handler(request, usage_key_string, view_name):
if view_name == 'reorderable_container_child_preview':
reorderable_items.add(xblock.location)
+ paging = None
+ try:
+ if request.REQUEST.get('enable_paging', 'false') == 'true':
+ paging = {
+ 'page_number': int(request.REQUEST.get('page_number', 0)),
+ 'page_size': int(request.REQUEST.get('page_size', 0)),
+ }
+ except ValueError:
+ log.exception(
+ "Couldn't parse paging parameters: enable_paging: %s, page_number: %s, page_size: %s",
+ request.REQUEST.get('enable_paging', 'false'),
+ request.REQUEST.get('page_number', 0),
+ request.REQUEST.get('page_size', 0)
+ )
+
# Set up the context to be passed to each XBlock's render method.
context = {
'is_pages_view': is_pages_view, # This setting disables the recursive wrapping of xblocks
'is_unit_page': is_unit(xblock),
'root_xblock': xblock if (view_name == 'container_preview') else None,
- 'reorderable_items': reorderable_items
+ 'reorderable_items': reorderable_items,
+ 'paging': paging
}
fragment = get_preview_fragment(request, xblock, context)
diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee
index b9b3546dbff3..e2ba18a82198 100644
--- a/cms/static/coffee/spec/main.coffee
+++ b/cms/static/coffee/spec/main.coffee
@@ -232,6 +232,7 @@ define([
"js/spec/views/assets_spec",
"js/spec/views/baseview_spec",
"js/spec/views/container_spec",
+ "js/spec/views/library_container_spec",
"js/spec/views/group_configuration_spec",
"js/spec/views/paging_spec",
"js/spec/views/unit_outline_spec",
diff --git a/cms/static/js/factories/container.js b/cms/static/js/factories/container.js
index 93cdeb8fd991..ea48bb2a989f 100644
--- a/cms/static/js/factories/container.js
+++ b/cms/static/js/factories/container.js
@@ -1,22 +1,20 @@
define([
- 'jquery', 'js/models/xblock_info', 'js/views/pages/container',
+ 'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/container',
'js/collections/component_template', 'xmodule', 'coffee/src/main',
'xblock/cms.runtime.v1'
],
-function($, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
+function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
'use strict';
- return function (componentTemplates, XBlockInfoJson, action, isUnitPage) {
- var templates = new ComponentTemplates(componentTemplates, {parse: true}),
- mainXBlockInfo = new XBlockInfo(XBlockInfoJson, {parse: true});
-
- xmoduleLoader.done(function () {
- var view = new ContainerPage({
+ return function (componentTemplates, XBlockInfoJson, action, options) {
+ var main_options = {
el: $('#content'),
- model: mainXBlockInfo,
+ model: new XBlockInfo(XBlockInfoJson, {parse: true}),
action: action,
- templates: templates,
- isUnitPage: isUnitPage
- });
+ templates: new ComponentTemplates(componentTemplates, {parse: true})
+ };
+
+ xmoduleLoader.done(function () {
+ var view = new ContainerPage(_.extend(main_options, options));
view.render();
});
};
diff --git a/cms/static/js/factories/library.js b/cms/static/js/factories/library.js
index 2729a3cf279d..e7834f60ef3c 100644
--- a/cms/static/js/factories/library.js
+++ b/cms/static/js/factories/library.js
@@ -1,22 +1,20 @@
define([
- 'jquery', 'js/models/xblock_info', 'js/views/pages/container',
+ 'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/container',
'js/collections/component_template', 'xmodule', 'coffee/src/main',
'xblock/cms.runtime.v1'
],
-function($, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
+function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
'use strict';
- return function (componentTemplates, XBlockInfoJson) {
- var templates = new ComponentTemplates(componentTemplates, {parse: true}),
- mainXBlockInfo = new XBlockInfo(XBlockInfoJson, {parse: true});
+ return function (componentTemplates, XBlockInfoJson, options) {
+ var main_options = {
+ el: $('#content'),
+ model: new XBlockInfo(XBlockInfoJson, {parse: true}),
+ templates: new ComponentTemplates(componentTemplates, {parse: true}),
+ action: 'view'
+ };
xmoduleLoader.done(function () {
- var view = new ContainerPage({
- el: $('#content'),
- model: mainXBlockInfo,
- action: "view",
- templates: templates,
- isUnitPage: false
- });
+ var view = new ContainerPage(_.extend(main_options, options));
view.render();
});
};
diff --git a/cms/static/js/spec/views/library_container_spec.js b/cms/static/js/spec/views/library_container_spec.js
new file mode 100644
index 000000000000..2d39cdc35819
--- /dev/null
+++ b/cms/static/js/spec/views/library_container_spec.js
@@ -0,0 +1,489 @@
+define([ "jquery", "underscore", "js/common_helpers/ajax_helpers", "URI", "js/models/xblock_info",
+ "js/views/library_container", "js/views/paging_header", "js/views/paging_footer"],
+ function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingContainer, PagingFooter) {
+
+ var htmlResponseTpl = _.template('' +
+ ''
+ );
+
+ function getResponseHtml(options){
+ return '
+
diff --git a/cms/templates/library.html b/cms/templates/library.html
index 70bd836baddd..dc9baa5736c3 100644
--- a/cms/templates/library.html
+++ b/cms/templates/library.html
@@ -22,8 +22,12 @@
<%block name="requirejs">
require(["js/factories/library"], function(LibraryFactory) {
LibraryFactory(
- ${component_templates | n},
- ${json.dumps(xblock_info) | n}
+ ${component_templates | n}, ${json.dumps(xblock_info) | n},
+ {
+ isUnitPage: false,
+ enable_paging: true,
+ page_size: 10
+ }
);
});
%block>
diff --git a/common/lib/xmodule/xmodule/library_root_xblock.py b/common/lib/xmodule/xmodule/library_root_xblock.py
index dc00aaa97fa9..497a145b79bd 100644
--- a/common/lib/xmodule/xmodule/library_root_xblock.py
+++ b/common/lib/xmodule/xmodule/library_root_xblock.py
@@ -3,10 +3,10 @@
"""
import logging
-from .studio_editable import StudioEditableModule
from xblock.core import XBlock
from xblock.fields import Scope, String, List
from xblock.fragment import Fragment
+from xmodule.studio_editable import StudioEditableModule
log = logging.getLogger(__name__)
@@ -42,29 +42,55 @@ def __str__(self):
def author_view(self, context):
"""
- Renders the Studio preview view, which supports drag and drop.
+ Renders the Studio preview view.
"""
fragment = Fragment()
+ self.render_children(context, fragment, can_reorder=False, can_add=True)
+ return fragment
+
+ def render_children(self, context, fragment, can_reorder=False, can_add=False): # pylint: disable=unused-argument
+ """
+ Renders the children of the module with HTML appropriate for Studio. If can_reorder is True,
+ then the children will be rendered to support drag and drop.
+ """
contents = []
- for child_key in self.children: # pylint: disable=E1101
- context['reorderable_items'].add(child_key)
+ paging = context.get('paging', None)
+
+ children_count = len(self.children) # pylint: disable=no-member
+ item_start, item_end = 0, children_count
+
+ # TODO sort children
+ if paging:
+ page_number = paging.get('page_number', 0)
+ raw_page_size = paging.get('page_size', None)
+ page_size = raw_page_size if raw_page_size is not None else children_count
+ item_start, item_end = page_size * page_number, page_size * (page_number + 1)
+
+ children_to_show = self.children[item_start:item_end] # pylint: disable=no-member
+
+ for child_key in children_to_show: # pylint: disable=E1101
child = self.runtime.get_block(child_key)
- rendered_child = self.runtime.render_child(child, StudioEditableModule.get_preview_view_name(child), context)
+ child_view_name = StudioEditableModule.get_preview_view_name(child)
+ rendered_child = self.runtime.render_child(child, child_view_name, context)
fragment.add_frag_resources(rendered_child)
contents.append({
- 'id': unicode(child_key),
- 'content': rendered_child.content,
+ 'id': child.location.to_deprecated_string(),
+ 'content': rendered_child.content
})
- fragment.add_content(self.runtime.render_template("studio_render_children_view.html", {
- 'items': contents,
- 'xblock_context': context,
- 'can_add': True,
- 'can_reorder': True,
- }))
- return fragment
+ fragment.add_content(
+ self.runtime.render_template("studio_render_paged_children_view.html", {
+ 'items': contents,
+ 'xblock_context': context,
+ 'can_add': can_add,
+ 'can_reorder': False,
+ 'first_displayed': item_start,
+ 'total_children': children_count,
+ 'displayed_children': len(children_to_show)
+ })
+ )
@property
def display_org_with_default(self):
diff --git a/common/lib/xmodule/xmodule/video_module/video_handlers.py b/common/lib/xmodule/xmodule/video_module/video_handlers.py
index 9e9db860ca54..1ba427c35785 100644
--- a/common/lib/xmodule/xmodule/video_module/video_handlers.py
+++ b/common/lib/xmodule/xmodule/video_module/video_handlers.py
@@ -155,7 +155,6 @@ def get_static_transcript(self, request):
if transcript_name:
# Get the asset path for course
- asset_path = None
course = self.descriptor.runtime.modulestore.get_course(self.course_id)
if course.static_asset_path:
asset_path = course.static_asset_path
diff --git a/lms/templates/studio_render_paged_children_view.html b/lms/templates/studio_render_paged_children_view.html
new file mode 100644
index 000000000000..fe5b5403e1ab
--- /dev/null
+++ b/lms/templates/studio_render_paged_children_view.html
@@ -0,0 +1,23 @@
+<%! from django.utils.translation import ugettext as _ %>
+
+<%namespace name='static' file='static_content.html'/>
+
+% for template_name in ["paging-header", "paging-footer"]:
+
+% endfor
+
+
+
+
+
+% for item in items:
+ ${item['content']}
+% endfor
+
+% if can_add:
+
+% endif
+
+
From 523e8d98a92c9e7d8a4adc49d1c25ed55151242f Mon Sep 17 00:00:00 2001
From: Jonathan Piacenti
Date: Thu, 4 Dec 2014 21:25:52 +0000
Subject: [PATCH 04/23] Added tests for Library pagination.
---
.../test/acceptance/pages/studio/library.py | 53 ++++++
.../tests/studio/test_studio_library.py | 161 ++++++++++++++++++
2 files changed, 214 insertions(+)
diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py
index e87c556da968..5572b1a91e43 100644
--- a/common/test/acceptance/pages/studio/library.py
+++ b/common/test/acceptance/pages/studio/library.py
@@ -3,6 +3,7 @@
"""
from bok_choy.page_object import PageObject
+from selenium.webdriver.common.keys import Keys
from .container import XBlockWrapper
from ...tests.helpers import disable_animations
from .utils import confirm_prompt, wait_for_notification
@@ -74,6 +75,58 @@ def click_delete_button(self, xblock_id, confirm=True):
confirm_prompt(self) # this will also wait_for_notification()
self.wait_for_ajax()
+ def nav_disabled(self, position, arrows=('next', 'previous')):
+ """
+ Verifies that pagination nav is disabled. Position can be 'top' or 'bottom'.
+
+ To specify a specific arrow, pass an iterable with a single element, 'next' or 'previous'.
+ """
+ return all([
+ self.q(css='nav.%s * a.%s-page-link.is-disabled' % (position, arrow))
+ for arrow in arrows
+ ])
+
+ def move_back(self, position):
+ """
+ Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
+ """
+ self.q(css='nav.%s * a.previous-page-link' % position)[0].click()
+ self.wait_until_ready()
+
+ def move_forward(self, position):
+ """
+ Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
+ """
+ self.q(css='nav.%s * a.next-page-link' % position)[0].click()
+ self.wait_until_ready()
+
+ def revisit(self):
+ """
+ Visit the page's URL, instead of refreshing, so that a new state is created.
+ """
+ self.browser.get(self.browser.current_url)
+ self.wait_until_ready()
+
+ def go_to_page(self, number):
+ """
+ Enter a number into the page number input field, and then try to navigate to it.
+ """
+ page_input = self.q(css="#page-number-input")[0]
+ page_input.click()
+ page_input.send_keys(str(number))
+ page_input.send_keys(Keys.RETURN)
+ self.wait_until_ready()
+
+ def check_page_unchanged(self, first_block_name):
+ """
+ Used to make sure that a page has not transitioned after a bogus number is given.
+ """
+ if not self.xblocks[0].name == first_block_name:
+ return False
+ if not self.q(css='#page-number-input')[0].get_attribute('value') == '':
+ return False
+ return True
+
def _get_xblocks(self):
"""
Create an XBlockWrapper for each XBlock div found on the page.
diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py
index b505ac140dbc..5529f3603293 100644
--- a/common/test/acceptance/tests/studio/test_studio_library.py
+++ b/common/test/acceptance/tests/studio/test_studio_library.py
@@ -1,11 +1,14 @@
"""
Acceptance tests for Content Libraries in Studio
"""
+from ddt import ddt, data
+
from .base_studio_test import StudioLibraryTest
from ...pages.studio.utils import add_component
from ...pages.studio.library import LibraryPage
+@ddt
class LibraryEditPageTest(StudioLibraryTest):
"""
Test the functionality of the library edit page.
@@ -107,3 +110,161 @@ def test_no_discussion_button(self):
Ensure the UI is not loaded for adding discussions.
"""
self.assertFalse(self.browser.find_elements_by_css_selector('span.large-discussion-icon'))
+
+ def test_library_pagination(self):
+ """
+ Scenario: Ensure that adding several XBlocks to a library results in pagination.
+ Given that I have a library in Studio with no XBlocks
+ And I create 10 Multiple Choice XBlocks
+ Then 10 are displayed.
+ When I add one more Multiple Choice XBlock
+ Then 1 XBlock will be displayed
+ When I delete that XBlock
+ Then 10 are displayed.
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 0)
+ for _ in range(0, 10):
+ add_component(self.lib_page, "problem", "Multiple Choice")
+ self.assertEqual(len(self.lib_page.xblocks), 10)
+ add_component(self.lib_page, "problem", "Multiple Choice")
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator)
+ self.assertEqual(len(self.lib_page.xblocks), 10)
+
+ @data('top', 'bottom')
+ def test_nav_present_but_disabled(self, position):
+ """
+ Scenario: Ensure that the navigation buttons aren't active when there aren't enough XBlocks.
+ Given that I have a library in Studio with no XBlocks
+ The Navigation buttons should be disabled.
+ When I add 5 multiple Choice XBlocks
+ The Navigation buttons should be disabled.
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 0)
+ self.assertTrue(self.lib_page.nav_disabled(position))
+ for _ in range(0, 5):
+ add_component(self.lib_page, "problem", "Multiple Choice")
+ self.assertTrue(self.lib_page.nav_disabled(position))
+
+ @data('top', 'bottom')
+ def test_nav_buttons(self, position):
+ """
+ Scenario: Ensure that the navigation buttons work.
+ Given that I have a library in Studio with no XBlocks
+ And I create 10 Multiple Choice XBlocks
+ And I create 10 Checkbox XBlocks
+ And I create 10 Dropdown XBlocks
+ And I revisit the page
+ The previous button should be disabled.
+ The first XBlock should be a Multiple Choice XBlock
+ Then if I hit the next button
+ The first XBlock should be a Checkboxes XBlock
+ Then if I hit the next button
+ The first XBlock should be a Dropdown XBlock
+ And the next button should be disabled
+ Then if I hit the previous button
+ The first XBlock should be an Checkboxes XBlock
+ Then if I hit the previous button
+ The first XBlock should be a Multipe Choice XBlock
+ And the previous button should be disabled
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 0)
+ block_types = [('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown')]
+ for block_type in block_types:
+ for _ in range(0, 10):
+ add_component(self.lib_page, *block_type)
+
+ # Don't refresh, as that may contain additional state.
+ self.lib_page.revisit()
+
+ # Check forward navigation
+ self.assertTrue(self.lib_page.nav_disabled(position, ['previous']))
+ self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice')
+ self.lib_page.move_forward(position)
+ self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes')
+ self.lib_page.move_forward(position)
+ self.assertEqual(self.lib_page.xblocks[0].name, 'Dropdown')
+ self.lib_page.nav_disabled(position, ['next'])
+
+ # Check backward navigation
+ self.lib_page.move_back(position)
+ self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes')
+ self.lib_page.move_back(position)
+ self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice')
+ self.assertTrue(self.lib_page.nav_disabled(position, ['previous']))
+
+ def test_arbitrary_page_selection(self):
+ """
+ Scenario: I can pick a specific page number of a Library at will.
+ Given that I have a library in Studio with no XBlocks
+ And I create 10 Multiple Choice XBlocks
+ And I create 10 Checkboxes XBlocks
+ And I create 10 Dropdown XBlocks
+ And I create 10 Numerical Input XBlocks
+ And I revisit the page
+ When I go to the 3rd page
+ The first XBlock should be a Dropdown XBlock
+ When I go to the 4th Page
+ The first XBlock should be a Numerical Input XBlock
+ When I go to the 1st page
+ The first XBlock should be a Multiple Choice XBlock
+ When I go to the 2nd page
+ The first XBlock should be a Checkboxes XBlock
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 0)
+ block_types = [
+ ('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown'),
+ ('problem', 'Numerical Input'),
+ ]
+ for block_type in block_types:
+ for _ in range(0, 10):
+ add_component(self.lib_page, *block_type)
+
+ # Don't refresh, as that may contain additional state.
+ self.lib_page.revisit()
+ self.lib_page.go_to_page(3)
+ self.assertEqual(self.lib_page.xblocks[0].name, 'Dropdown')
+ self.lib_page.go_to_page(4)
+ self.assertEqual(self.lib_page.xblocks[0].name, 'Numerical Input')
+ self.lib_page.go_to_page(1)
+ self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice')
+ self.lib_page.go_to_page(2)
+ self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes')
+
+ def test_bogus_page_selection(self):
+ """
+ Scenario: I can't pick a nonsense page number of a Library
+ Given that I have a library in Studio with no XBlocks
+ And I create 10 Multiple Choice XBlocks
+ And I create 10 Checkboxes XBlocks
+ And I create 10 Dropdown XBlocks
+ And I create 10 Numerical Input XBlocks
+ And I revisit the page
+ When I attempt to go to the 'a'th page
+ The input field will be cleared and no change of XBlocks will be made
+ When I attempt to visit the 5th page
+ The input field will be cleared and no change of XBlocks will be made
+ When I attempt to visit the -1st page
+ The input field will be cleared and no change of XBlocks will be made
+ When I attempt to visit the 0th page
+ The input field will be cleared and no change of XBlocks will be made
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 0)
+ block_types = [
+ ('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown'),
+ ('problem', 'Numerical Input'),
+ ]
+ for block_type in block_types:
+ for _ in range(0, 10):
+ add_component(self.lib_page, *block_type)
+
+ self.lib_page.revisit()
+ self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice')
+ self.lib_page.go_to_page('a')
+ self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice'))
+ self.lib_page.go_to_page(-1)
+ self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice'))
+ self.lib_page.go_to_page(5)
+ self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice'))
+ self.lib_page.go_to_page(0)
+ self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice'))
From 26e9399b39b16381de57e97bde7f2061c09ea0f5 Mon Sep 17 00:00:00 2001
From: Jonathan Piacenti
Date: Mon, 8 Dec 2014 22:22:02 +0000
Subject: [PATCH 05/23] Addressed notes from reviewers on Library Pagination.
---
cms/djangoapps/contentstore/views/item.py | 5 +-
.../js/spec/views/pages/container_spec.js | 32 +-
cms/static/js/views/library_container.js | 71 +++--
cms/static/js/views/pages/container.js | 23 +-
cms/static/sass/elements/_pagination.scss | 8 +-
cms/static/sass/elements/_xblocks.scss | 2 +-
...ontainer-paged-after-add-xblock.underscore | 283 ------------------
.../js/mock/mock-xblock-paged.underscore | 21 ++
.../xmodule/xmodule/library_root_xblock.py | 3 +-
.../xmodule/video_module/video_handlers.py | 1 +
10 files changed, 113 insertions(+), 336 deletions(-)
delete mode 100644 cms/templates/js/mock/mock-container-paged-after-add-xblock.underscore
create mode 100644 cms/templates/js/mock/mock-xblock-paged.underscore
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 5d05a27e1781..7f68dcd78481 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -207,6 +207,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
store = modulestore()
xblock = store.get_item(usage_key)
container_views = ['container_preview', 'reorderable_container_child_preview']
+ library = isinstance(usage_key, LibraryUsageLocator)
# wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly
@@ -235,7 +236,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
# are being shown in a reorderable container, so the xblock is automatically
# added to the list.
reorderable_items = set()
- if view_name == 'reorderable_container_child_preview':
+ if not library and view_name == 'reorderable_container_child_preview':
reorderable_items.add(xblock.location)
paging = None
@@ -259,7 +260,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
'is_unit_page': is_unit(xblock),
'root_xblock': xblock if (view_name == 'container_preview') else None,
'reorderable_items': reorderable_items,
- 'paging': paging
+ 'paging': paging,
}
fragment = get_preview_fragment(request, xblock, context)
diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js
index 6f4b4baf46af..d5ec6938dc25 100644
--- a/cms/static/js/spec/views/pages/container_spec.js
+++ b/cms/static/js/spec/views/pages/container_spec.js
@@ -273,7 +273,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
});
describe("xblock operations", function () {
- var getGroupElement,
+ var getGroupElement, paginated,
NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A",
allComponentsInGroup = _.map(
_.range(NUM_COMPONENTS_PER_GROUP),
@@ -282,6 +282,11 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
}
);
+ paginated = function () {
+ return containerPage.enable_paging;
+ };
+
+
getGroupElement = function () {
return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']");
};
@@ -294,6 +299,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
promptSpy = EditHelpers.createPromptSpy();
});
+
clickDelete = function (componentIndex, clickNo) {
// find all delete buttons for the given group
@@ -307,21 +313,25 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
EditHelpers.confirmPrompt(promptSpy, clickNo);
};
- deleteComponent = function (componentIndex) {
+ deleteComponent = function (componentIndex, requestOffset) {
clickDelete(componentIndex);
AjaxHelpers.respondWithJson(requests, {});
// second to last request contains given component's id (to delete the component)
AjaxHelpers.expectJsonRequest(requests, 'DELETE',
'/xblock/locator-component-' + GROUP_TO_TEST + (componentIndex + 1),
- null, requests.length - 2);
+ null, requests.length - requestOffset);
// final request to refresh the xblock info
AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
};
deleteComponentWithSuccess = function (componentIndex) {
- deleteComponent(componentIndex);
+ var deleteOffset;
+
+ deleteOffset = paginated() ? 3 : 2;
+
+ deleteComponent(componentIndex, deleteOffset);
// verify the new list of components within the group
expectComponents(
@@ -350,9 +360,16 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
containerPage.$('.delete-button').first().click();
EditHelpers.confirmPrompt(promptSpy);
AjaxHelpers.respondWithJson(requests, {});
+ var deleteOffset;
+
+ if (paginated()) {
+ deleteOffset = 3;
+ } else {
+ deleteOffset = 2;
+ }
// expect the second to last request to be a delete of the xblock
AjaxHelpers.expectJsonRequest(requests, 'DELETE', '/xblock/locator-broken-javascript',
- null, requests.length - 2);
+ null, requests.length - deleteOffset);
// expect the last request to be a fetch of the xblock info for the parent container
AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
});
@@ -511,7 +528,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
});
describe('Template Picker', function () {
- var showTemplatePicker, verifyCreateHtmlComponent;
+ var showTemplatePicker, verifyCreateHtmlComponent, call_count;
showTemplatePicker = function () {
containerPage.$('.new-component .new-component-type a.multiple-templates')[0].click();
@@ -519,6 +536,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
verifyCreateHtmlComponent = function (test, templateIndex, expectedRequest) {
var xblockCount;
+ // call_count = paginated() ? 18: 10;
renderContainerPage(test, mockContainerXBlockHtml);
showTemplatePicker();
xblockCount = containerPage.$('.studio-xblock-wrapper').length;
@@ -557,6 +575,6 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
{ enable_paging: true, page_size: 42 },
{
initial: 'mock/mock-container-paged-xblock.underscore',
- add_response: 'mock/mock-container-paged-after-add-xblock.underscore'
+ add_response: 'mock/mock-xblock-paged.underscore'
});
});
diff --git a/cms/static/js/views/library_container.js b/cms/static/js/views/library_container.js
index b655833289c9..a8c15999ab3f 100644
--- a/cms/static/js/views/library_container.js
+++ b/cms/static/js/views/library_container.js
@@ -1,19 +1,16 @@
-define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification",
+define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettext", "js/views/feedback_notification",
"js/views/paging_header", "js/views/paging_footer"],
- function ($, _, XBlockView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter) {
- var LibraryContainerView = XBlockView.extend({
+ function ($, _, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter) {
+ var LibraryContainerView = ContainerView.extend({
// Store the request token of the first xblock on the page (which we know was rendered by Studio when
// the page was generated). Use that request token to filter out user-defined HTML in any
// child xblocks within the page.
- requestToken: "",
initialize: function(options){
var self = this;
- XBlockView.prototype.initialize.call(this);
+ ContainerView.prototype.initialize.call(this);
this.page_size = this.options.page_size || 10;
- if (options) {
- this.page_reload_callback = options.page_reload_callback;
- }
+ this.page_reload_callback = options.page_reload_callback || function () {};
// emulating Backbone.paginator interface
this.collection = {
currentPage: 0,
@@ -30,9 +27,6 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
render: function(options) {
var eff_options = options || {};
- if (eff_options.block_added) {
- this.collection.currentPage = this.getPageCount(this.collection.totalCount+1) - 1;
- }
eff_options.page_number = typeof eff_options.page_number !== "undefined"
? eff_options.page_number
: this.collection.currentPage;
@@ -53,9 +47,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
success: function(fragment) {
self.handleXBlockFragment(fragment, options);
self.processPaging({ requested_page: options.page_number });
- if (options.paging && self.page_reload_callback){
- self.page_reload_callback(self.$el);
- }
+ // This is expected to render the add xblock components menu.
+ self.page_reload_callback(self.$el)
}
});
},
@@ -69,12 +62,12 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
},
getPageCount: function(total_count){
- if (total_count==0) return 1;
+ if (total_count===0) return 1;
return Math.ceil(total_count / this.page_size);
},
setPage: function(page_number) {
- this.render({ page_number: page_number, paging: true });
+ this.render({ page_number: page_number});
},
nextPage: function() {
@@ -129,32 +122,54 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
},
xblockReady: function () {
- XBlockView.prototype.xblockReady.call(this);
+ ContainerView.prototype.xblockReady.call(this);
this.requestToken = this.$('div.xblock').first().data('request-token');
},
- refresh: function() { },
+ refresh: function(block_added) {
+ if (block_added) {
+ this.collection.totalCount += 1;
+ this.collection._size +=1;
+ if (this.collection.totalCount == 1) {
+ this.render();
+ return
+ }
+ this.collection.totalPages = this.getPageCount(this.collection.totalCount);
+ var new_page = this.collection.totalPages - 1;
+ // If we're on a new page due to overflow, or this is the first item, set the page.
+ if (((this.collection.currentPage) != new_page) || this.collection.totalCount == 1) {
+ this.setPage(new_page);
+ } else {
+ this.pagingHeader.render();
+ this.pagingFooter.render();
+ }
+ }
+ },
acknowledgeXBlockDeletion: function (locator){
this.notifyRuntime('deleted-child', locator);
this.collection._size -= 1;
this.collection.totalCount -= 1;
- // pages are counted from 0 - thus currentPage == 1 if we're on second page
- if (this.collection._size == 0 && this.collection.currentPage >= 1) {
- this.setPage(this.collection.currentPage - 1);
- this.collection.totalPages -= 1;
- }
- else {
+ var current_page = this.collection.currentPage;
+ var total_pages = this.getPageCount(this.collection.totalCount);
+ this.collection.totalPages = total_pages;
+ // Starts counting from 0
+ if ((current_page + 1) > total_pages) {
+ // The number of total pages has changed. Move down.
+ // Also, be mindful of the off-by-one.
+ this.setPage(total_pages - 1)
+ } else if ((current_page + 1) != total_pages) {
+ // Refresh page to get any blocks shifted from the next page.
+ this.setPage(current_page)
+ } else {
+ // We're on the last page, just need to update the numbers in the
+ // pagination interface.
this.pagingHeader.render();
this.pagingFooter.render();
}
},
- makeRequestSpecificSelector: function(selector) {
- return 'div.xblock[data-request-token="' + this.requestToken + '"] > ' + selector;
- },
-
sortDisplayName: function() {
return "Date added"; // TODO add support for sorting
}
diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js
index c63e7f00f6e0..771e9afb1b89 100644
--- a/cms/static/js/views/pages/container.js
+++ b/cms/static/js/views/pages/container.js
@@ -119,8 +119,11 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
// Notify the runtime that the page has been successfully shown
xblockView.notifyRuntime('page-shown', self);
- // Render the add buttons
- self.renderAddXBlockComponents();
+ // Render the add buttons. Paged containers should do this on their own.
+ if (!self.enable_paging) {
+ // Render the add buttons
+ self.renderAddXBlockComponents();
+ }
// Refresh the views now that the xblock is visible
self.onXBlockRefresh(xblockView);
@@ -141,8 +144,8 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
return this.xblockView.model.urlRoot;
},
- onXBlockRefresh: function(xblockView) {
- this.xblockView.refresh();
+ onXBlockRefresh: function(xblockView, block_added) {
+ this.xblockView.refresh(block_added);
// Update publish and last modified information from the server.
this.model.fetch();
},
@@ -274,10 +277,10 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
rootLocator = this.xblockView.model.id;
if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) {
this.render({refresh: true, block_added: block_added});
- } else if (parentElement.hasClass('reorderable-container')) {
- this.refreshChildXBlock(xblockElement);
+ } else if (parentElement.hasClass('reorderable-container') || this.enable_paging) {
+ this.refreshChildXBlock(xblockElement, block_added);
} else {
- this.refreshXBlock(this.findXBlockElement(parentElement), block_added);
+ this.refreshXBlock(this.findXBlockElement(parentElement));
}
},
@@ -285,9 +288,11 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
* Refresh an xblock element inline on the page, using the specified xblockInfo.
* Note that the element is removed and replaced with the newly rendered xblock.
* @param xblockElement The xblock element to be refreshed.
+ * @param block_added Specifies if a block has been added, rather than just needs
+ * refreshing.
* @returns {jQuery promise} A promise representing the complete operation.
*/
- refreshChildXBlock: function(xblockElement) {
+ refreshChildXBlock: function(xblockElement, block_added) {
var self = this,
xblockInfo,
TemporaryXBlockView,
@@ -313,7 +318,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
});
return temporaryView.render({
success: function() {
- self.onXBlockRefresh(temporaryView);
+ self.onXBlockRefresh(temporaryView, block_added);
temporaryView.unbind(); // Remove the temporary view
}
});
diff --git a/cms/static/sass/elements/_pagination.scss b/cms/static/sass/elements/_pagination.scss
index f3ba465b8086..379d8785e315 100644
--- a/cms/static/sass/elements/_pagination.scss
+++ b/cms/static/sass/elements/_pagination.scss
@@ -2,7 +2,7 @@
// ==========================
%pagination {
- @include clearfix;
+ @include clearfix();
display: inline-block;
width: flex-grid(3, 12);
@@ -48,7 +48,7 @@
}
.nav-label {
- @extend .sr;
+ @extend %cont-text-sr;
}
.pagination-form,
@@ -89,7 +89,7 @@
.page-number-label,
.submit-pagination-form {
- @extend .sr;
+ @extend %cont-text-sr;
}
.page-number-input {
@@ -116,4 +116,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss
index 938f000d8e53..0473d748e7d6 100644
--- a/cms/static/sass/elements/_xblocks.scss
+++ b/cms/static/sass/elements/_xblocks.scss
@@ -105,7 +105,7 @@
.container-paging-header {
.meta-wrap {
- margin: $baseline $baseline/2;
+ margin: $baseline ($baseline/2);
}
.meta {
@extend %t-copy-sub2;
diff --git a/cms/templates/js/mock/mock-container-paged-after-add-xblock.underscore b/cms/templates/js/mock/mock-container-paged-after-add-xblock.underscore
deleted file mode 100644
index cb260c9bca9d..000000000000
--- a/cms/templates/js/mock/mock-container-paged-after-add-xblock.underscore
+++ /dev/null
@@ -1,283 +0,0 @@
-
-
-
diff --git a/common/lib/xmodule/xmodule/library_root_xblock.py b/common/lib/xmodule/xmodule/library_root_xblock.py
index 497a145b79bd..3118f9a25842 100644
--- a/common/lib/xmodule/xmodule/library_root_xblock.py
+++ b/common/lib/xmodule/xmodule/library_root_xblock.py
@@ -76,7 +76,7 @@ def render_children(self, context, fragment, can_reorder=False, can_add=False):
fragment.add_frag_resources(rendered_child)
contents.append({
- 'id': child.location.to_deprecated_string(),
+ 'id': unicode(child.location),
'content': rendered_child.content
})
@@ -85,7 +85,6 @@ def render_children(self, context, fragment, can_reorder=False, can_add=False):
'items': contents,
'xblock_context': context,
'can_add': can_add,
- 'can_reorder': False,
'first_displayed': item_start,
'total_children': children_count,
'displayed_children': len(children_to_show)
diff --git a/common/lib/xmodule/xmodule/video_module/video_handlers.py b/common/lib/xmodule/xmodule/video_module/video_handlers.py
index 1ba427c35785..9e9db860ca54 100644
--- a/common/lib/xmodule/xmodule/video_module/video_handlers.py
+++ b/common/lib/xmodule/xmodule/video_module/video_handlers.py
@@ -155,6 +155,7 @@ def get_static_transcript(self, request):
if transcript_name:
# Get the asset path for course
+ asset_path = None
course = self.descriptor.runtime.modulestore.get_course(self.course_id)
if course.static_asset_path:
asset_path = course.static_asset_path
From 1581a398248e54a85505706a216ccc6a9a4c2eb5 Mon Sep 17 00:00:00 2001
From: Jonathan Piacenti
Date: Thu, 11 Dec 2014 19:53:08 +0000
Subject: [PATCH 06/23] Factored out Pagination into its own Container view.
---
cms/static/js/views/library_container.js | 180 +----------------------
cms/static/js/views/paged_container.js | 158 ++++++++++++++++++++
cms/static/js/views/paging.js | 40 +----
cms/static/js/views/paging_mixin.js | 37 +++++
4 files changed, 202 insertions(+), 213 deletions(-)
create mode 100644 cms/static/js/views/paged_container.js
create mode 100644 cms/static/js/views/paging_mixin.js
diff --git a/cms/static/js/views/library_container.js b/cms/static/js/views/library_container.js
index a8c15999ab3f..7c48e83cee8f 100644
--- a/cms/static/js/views/library_container.js
+++ b/cms/static/js/views/library_container.js
@@ -1,179 +1,7 @@
-define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettext", "js/views/feedback_notification",
+define(["jquery", "underscore", "js/views/paged_container", "js/utils/module", "gettext", "js/views/feedback_notification",
"js/views/paging_header", "js/views/paging_footer"],
- function ($, _, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter) {
- var LibraryContainerView = ContainerView.extend({
- // Store the request token of the first xblock on the page (which we know was rendered by Studio when
- // the page was generated). Use that request token to filter out user-defined HTML in any
- // child xblocks within the page.
-
- initialize: function(options){
- var self = this;
- ContainerView.prototype.initialize.call(this);
- this.page_size = this.options.page_size || 10;
- this.page_reload_callback = options.page_reload_callback || function () {};
- // emulating Backbone.paginator interface
- this.collection = {
- currentPage: 0,
- totalPages: 0,
- totalCount: 0,
- sortDirection: "desc",
- start: 0,
- _size: 0,
-
- bind: function() {}, // no-op
- size: function() { return self.collection._size; }
- };
- },
-
- render: function(options) {
- var eff_options = options || {};
- eff_options.page_number = typeof eff_options.page_number !== "undefined"
- ? eff_options.page_number
- : this.collection.currentPage;
- return this.renderPage(eff_options);
- },
-
- renderPage: function(options){
- var self = this,
- view = this.view,
- xblockInfo = this.model,
- xblockUrl = xblockInfo.url();
- return $.ajax({
- url: decodeURIComponent(xblockUrl) + "/" + view,
- type: 'GET',
- cache: false,
- data: this.getRenderParameters(options.page_number),
- headers: { Accept: 'application/json' },
- success: function(fragment) {
- self.handleXBlockFragment(fragment, options);
- self.processPaging({ requested_page: options.page_number });
- // This is expected to render the add xblock components menu.
- self.page_reload_callback(self.$el)
- }
- });
- },
-
- getRenderParameters: function(page_number) {
- return {
- enable_paging: true,
- page_size: this.page_size,
- page_number: page_number
- };
- },
-
- getPageCount: function(total_count){
- if (total_count===0) return 1;
- return Math.ceil(total_count / this.page_size);
- },
-
- setPage: function(page_number) {
- this.render({ page_number: page_number});
- },
-
- nextPage: function() {
- var collection = this.collection,
- currentPage = collection.currentPage,
- lastPage = collection.totalPages - 1;
- if (currentPage < lastPage) {
- this.setPage(currentPage + 1);
- }
- },
-
- previousPage: function() {
- var collection = this.collection,
- currentPage = collection.currentPage;
- if (currentPage > 0) {
- this.setPage(currentPage - 1);
- }
- },
-
- processPaging: function(options){
- var $element = this.$el.find('.xblock-container-paging-parameters'),
- total = $element.data('total'),
- displayed = $element.data('displayed'),
- start = $element.data('start');
-
- this.collection.currentPage = options.requested_page;
- this.collection.totalCount = total;
- this.collection.totalPages = this.getPageCount(total);
- this.collection.start = start;
- this.collection._size = displayed;
-
- this.processPagingHeaderAndFooter();
- },
-
- processPagingHeaderAndFooter: function(){
- if (this.pagingHeader)
- this.pagingHeader.undelegateEvents();
- if (this.pagingFooter)
- this.pagingFooter.undelegateEvents();
-
- this.pagingHeader = new PagingHeader({
- view: this,
- el: this.$el.find('.container-paging-header')
- });
- this.pagingFooter = new PagingFooter({
- view: this,
- el: this.$el.find('.container-paging-footer')
- });
-
- this.pagingHeader.render();
- this.pagingFooter.render();
- },
-
- xblockReady: function () {
- ContainerView.prototype.xblockReady.call(this);
-
- this.requestToken = this.$('div.xblock').first().data('request-token');
- },
-
- refresh: function(block_added) {
- if (block_added) {
- this.collection.totalCount += 1;
- this.collection._size +=1;
- if (this.collection.totalCount == 1) {
- this.render();
- return
- }
- this.collection.totalPages = this.getPageCount(this.collection.totalCount);
- var new_page = this.collection.totalPages - 1;
- // If we're on a new page due to overflow, or this is the first item, set the page.
- if (((this.collection.currentPage) != new_page) || this.collection.totalCount == 1) {
- this.setPage(new_page);
- } else {
- this.pagingHeader.render();
- this.pagingFooter.render();
- }
- }
- },
-
- acknowledgeXBlockDeletion: function (locator){
- this.notifyRuntime('deleted-child', locator);
- this.collection._size -= 1;
- this.collection.totalCount -= 1;
- var current_page = this.collection.currentPage;
- var total_pages = this.getPageCount(this.collection.totalCount);
- this.collection.totalPages = total_pages;
- // Starts counting from 0
- if ((current_page + 1) > total_pages) {
- // The number of total pages has changed. Move down.
- // Also, be mindful of the off-by-one.
- this.setPage(total_pages - 1)
- } else if ((current_page + 1) != total_pages) {
- // Refresh page to get any blocks shifted from the next page.
- this.setPage(current_page)
- } else {
- // We're on the last page, just need to update the numbers in the
- // pagination interface.
- this.pagingHeader.render();
- this.pagingFooter.render();
- }
- },
-
- sortDisplayName: function() {
- return "Date added"; // TODO add support for sorting
- }
- });
-
+ function ($, _, PagedContainerView) {
+ // To be extended with Library-specific features later.
+ var LibraryContainerView = PagedContainerView;
return LibraryContainerView;
}); // end define();
diff --git a/cms/static/js/views/paged_container.js b/cms/static/js/views/paged_container.js
new file mode 100644
index 000000000000..cd7590156a17
--- /dev/null
+++ b/cms/static/js/views/paged_container.js
@@ -0,0 +1,158 @@
+define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettext",
+ "js/views/feedback_notification", "js/views/paging_header", "js/views/paging_footer", "js/views/paging_mixin"],
+ function ($, _, ContainerView, ModuleUtils, gettext, NotificationView, PagingHeader, PagingFooter, PagingMixin) {
+ var PagedContainerView = ContainerView.extend(PagingMixin).extend({
+ initialize: function(options){
+ var self = this;
+ ContainerView.prototype.initialize.call(this);
+ this.page_size = this.options.page_size || 10;
+ this.page_reload_callback = options.page_reload_callback || function () {};
+ // emulating Backbone.paginator interface
+ this.collection = {
+ currentPage: 0,
+ totalPages: 0,
+ totalCount: 0,
+ sortDirection: "desc",
+ start: 0,
+ _size: 0,
+
+ bind: function() {}, // no-op
+ size: function() { return self.collection._size; }
+ };
+ },
+
+ render: function(options) {
+ var eff_options = options || {};
+ eff_options.page_number = typeof eff_options.page_number !== "undefined"
+ ? eff_options.page_number
+ : this.collection.currentPage;
+ return this.renderPage(eff_options);
+ },
+
+ renderPage: function(options){
+ var self = this,
+ view = this.view,
+ xblockInfo = this.model,
+ xblockUrl = xblockInfo.url();
+ return $.ajax({
+ url: decodeURIComponent(xblockUrl) + "/" + view,
+ type: 'GET',
+ cache: false,
+ data: this.getRenderParameters(options.page_number),
+ headers: { Accept: 'application/json' },
+ success: function(fragment) {
+ self.handleXBlockFragment(fragment, options);
+ self.processPaging({ requested_page: options.page_number });
+ // This is expected to render the add xblock components menu.
+ self.page_reload_callback(self.$el)
+ }
+ });
+ },
+
+ getRenderParameters: function(page_number) {
+ return {
+ enable_paging: true,
+ page_size: this.page_size,
+ page_number: page_number
+ };
+ },
+
+ getPageCount: function(total_count){
+ if (total_count===0) return 1;
+ return Math.ceil(total_count / this.page_size);
+ },
+
+ setPage: function(page_number) {
+ this.render({ page_number: page_number});
+ },
+
+ processPaging: function(options){
+ var $element = this.$el.find('.xblock-container-paging-parameters'),
+ total = $element.data('total'),
+ displayed = $element.data('displayed'),
+ start = $element.data('start');
+
+ this.collection.currentPage = options.requested_page;
+ this.collection.totalCount = total;
+ this.collection.totalPages = this.getPageCount(total);
+ this.collection.start = start;
+ this.collection._size = displayed;
+
+ this.processPagingHeaderAndFooter();
+ },
+
+ processPagingHeaderAndFooter: function(){
+ if (this.pagingHeader)
+ this.pagingHeader.undelegateEvents();
+ if (this.pagingFooter)
+ this.pagingFooter.undelegateEvents();
+
+ this.pagingHeader = new PagingHeader({
+ view: this,
+ el: this.$el.find('.container-paging-header')
+ });
+ this.pagingFooter = new PagingFooter({
+ view: this,
+ el: this.$el.find('.container-paging-footer')
+ });
+
+ this.pagingHeader.render();
+ this.pagingFooter.render();
+ },
+
+ xblockReady: function () {
+ ContainerView.prototype.xblockReady.call(this);
+
+ this.requestToken = this.$('div.xblock').first().data('request-token');
+ },
+
+ refresh: function(block_added) {
+ if (block_added) {
+ this.collection.totalCount += 1;
+ this.collection._size +=1;
+ if (this.collection.totalCount == 1) {
+ this.render();
+ return
+ }
+ this.collection.totalPages = this.getPageCount(this.collection.totalCount);
+ var new_page = this.collection.totalPages - 1;
+ // If we're on a new page due to overflow, or this is the first item, set the page.
+ if (((this.collection.currentPage) != new_page) || this.collection.totalCount == 1) {
+ this.setPage(new_page);
+ } else {
+ this.pagingHeader.render();
+ this.pagingFooter.render();
+ }
+ }
+ },
+
+ acknowledgeXBlockDeletion: function (locator){
+ this.notifyRuntime('deleted-child', locator);
+ this.collection._size -= 1;
+ this.collection.totalCount -= 1;
+ var current_page = this.collection.currentPage;
+ var total_pages = this.getPageCount(this.collection.totalCount);
+ this.collection.totalPages = total_pages;
+ // Starts counting from 0
+ if ((current_page + 1) > total_pages) {
+ // The number of total pages has changed. Move down.
+ // Also, be mindful of the off-by-one.
+ this.setPage(total_pages - 1)
+ } else if ((current_page + 1) != total_pages) {
+ // Refresh page to get any blocks shifted from the next page.
+ this.setPage(current_page)
+ } else {
+ // We're on the last page, just need to update the numbers in the
+ // pagination interface.
+ this.pagingHeader.render();
+ this.pagingFooter.render();
+ }
+ },
+
+ sortDisplayName: function() {
+ return "Date added"; // TODO add support for sorting
+ }
+ });
+
+ return PagedContainerView;
+ }); // end define();
diff --git a/cms/static/js/views/paging.js b/cms/static/js/views/paging.js
index c6c3a491ca04..c4d9b1b602f5 100644
--- a/cms/static/js/views/paging.js
+++ b/cms/static/js/views/paging.js
@@ -1,7 +1,7 @@
-define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"],
- function(_, BaseView, AlertView, gettext) {
+define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext", "js/views/paging_mixin"],
+ function(_, BaseView, AlertView, gettext, PagingMixin) {
- var PagingView = BaseView.extend({
+ var PagingView = BaseView.extend(PagingMixin).extend({
// takes a Backbone Paginator as a model
sortableColumns: {},
@@ -21,43 +21,10 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"]
this.$('#' + sortColumn).addClass('current-sort');
},
- setPage: function(page) {
- var self = this,
- collection = self.collection,
- oldPage = collection.currentPage;
- collection.goTo(page, {
- reset: true,
- success: function() {
- window.scrollTo(0, 0);
- },
- error: function(collection) {
- collection.currentPage = oldPage;
- self.onError();
- }
- });
- },
-
onError: function() {
// Do nothing by default
},
- nextPage: function() {
- var collection = this.collection,
- currentPage = collection.currentPage,
- lastPage = collection.totalPages - 1;
- if (currentPage < lastPage) {
- this.setPage(currentPage + 1);
- }
- },
-
- previousPage: function() {
- var collection = this.collection,
- currentPage = collection.currentPage;
- if (currentPage > 0) {
- this.setPage(currentPage - 1);
- }
- },
-
/**
* Registers information about a column that can be sorted.
* @param columnName The element name of the column.
@@ -110,6 +77,5 @@ define(["underscore", "js/views/baseview", "js/views/feedback_alert", "gettext"]
this.setPage(0);
}
});
-
return PagingView;
}); // end define();
diff --git a/cms/static/js/views/paging_mixin.js b/cms/static/js/views/paging_mixin.js
new file mode 100644
index 000000000000..d2c1700e5d64
--- /dev/null
+++ b/cms/static/js/views/paging_mixin.js
@@ -0,0 +1,37 @@
+define(["jquery", "underscore"],
+ function ($, _) {
+ var PagedMixin = {
+ setPage: function (page) {
+ var self = this,
+ collection = self.collection,
+ oldPage = collection.currentPage;
+ collection.goTo(page, {
+ reset: true,
+ success: function () {
+ window.scrollTo(0, 0);
+ },
+ error: function (collection) {
+ collection.currentPage = oldPage;
+ self.onError();
+ }
+ });
+ },
+ nextPage: function() {
+ var collection = this.collection,
+ currentPage = collection.currentPage,
+ lastPage = collection.totalPages - 1;
+ if (currentPage < lastPage) {
+ this.setPage(currentPage + 1);
+ }
+ },
+
+ previousPage: function() {
+ var collection = this.collection,
+ currentPage = collection.currentPage;
+ if (currentPage > 0) {
+ this.setPage(currentPage - 1);
+ }
+ }
+ };
+ return PagedMixin;
+ });
From be3371ee85cb629f91705c2ae045cf418d3a286a Mon Sep 17 00:00:00 2001
From: Jonathan Piacenti
Date: Fri, 12 Dec 2014 19:19:56 +0000
Subject: [PATCH 07/23] Addressed further review notes for Library Pagination
---
cms/djangoapps/contentstore/views/item.py | 19 +-
cms/static/coffee/spec/main.coffee | 2 +-
cms/static/js/factories/container.js | 10 +-
cms/static/js/factories/library.js | 11 +-
...tainer_spec.js => paged_container_spec.js} | 4 +-
.../js/spec/views/pages/container_spec.js | 43 ++--
cms/static/js/views/container.js | 2 +
cms/static/js/views/library_container.js | 5 +-
cms/static/js/views/paged_container.js | 44 ++--
cms/static/js/views/pages/container.js | 50 ++--
cms/static/js/views/pages/paged_container.js | 36 +++
cms/static/js/views/paging_footer.js | 2 +
cms/static/js/views/paging_mixin.js | 4 +-
cms/templates/library.html | 1 -
.../xmodule/xmodule/library_root_xblock.py | 5 +-
.../test/acceptance/pages/studio/library.py | 56 +----
.../acceptance/pages/studio/pagination.py | 62 +++++
.../tests/studio/test_studio_library.py | 222 ++++++++++--------
18 files changed, 334 insertions(+), 244 deletions(-)
rename cms/static/js/spec/views/{library_container_spec.js => paged_container_spec.js} (99%)
create mode 100644 cms/static/js/views/pages/paged_container.js
create mode 100644 common/test/acceptance/pages/studio/pagination.py
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index 7f68dcd78481..c1f627009cd0 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -206,8 +206,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
if 'application/json' in accept_header:
store = modulestore()
xblock = store.get_item(usage_key)
- container_views = ['container_preview', 'reorderable_container_child_preview']
- library = isinstance(usage_key, LibraryUsageLocator)
+ container_views = ['container_preview', 'reorderable_container_child_preview', 'container_child_preview']
# wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly
@@ -236,7 +235,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
# are being shown in a reorderable container, so the xblock is automatically
# added to the list.
reorderable_items = set()
- if not library and view_name == 'reorderable_container_child_preview':
+ if view_name == 'reorderable_container_child_preview':
reorderable_items.add(xblock.location)
paging = None
@@ -247,11 +246,15 @@ def xblock_view_handler(request, usage_key_string, view_name):
'page_size': int(request.REQUEST.get('page_size', 0)),
}
except ValueError:
- log.exception(
- "Couldn't parse paging parameters: enable_paging: %s, page_number: %s, page_size: %s",
- request.REQUEST.get('enable_paging', 'false'),
- request.REQUEST.get('page_number', 0),
- request.REQUEST.get('page_size', 0)
+ return HttpResponse(
+ content="Couldn't parse paging parameters: enable_paging: "
+ "%s, page_number: %s, page_size: %s".format(
+ request.REQUEST.get('enable_paging', 'false'),
+ request.REQUEST.get('page_number', 0),
+ request.REQUEST.get('page_size', 0)
+ ),
+ status=400,
+ content_type="text/plain",
)
# Set up the context to be passed to each XBlock's render method.
diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee
index e2ba18a82198..ba732d20c166 100644
--- a/cms/static/coffee/spec/main.coffee
+++ b/cms/static/coffee/spec/main.coffee
@@ -232,7 +232,7 @@ define([
"js/spec/views/assets_spec",
"js/spec/views/baseview_spec",
"js/spec/views/container_spec",
- "js/spec/views/library_container_spec",
+ "js/spec/views/paged_container_spec",
"js/spec/views/group_configuration_spec",
"js/spec/views/paging_spec",
"js/spec/views/unit_outline_spec",
diff --git a/cms/static/js/factories/container.js b/cms/static/js/factories/container.js
index ea48bb2a989f..429ae58f5151 100644
--- a/cms/static/js/factories/container.js
+++ b/cms/static/js/factories/container.js
@@ -7,11 +7,11 @@ function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
'use strict';
return function (componentTemplates, XBlockInfoJson, action, options) {
var main_options = {
- el: $('#content'),
- model: new XBlockInfo(XBlockInfoJson, {parse: true}),
- action: action,
- templates: new ComponentTemplates(componentTemplates, {parse: true})
- };
+ el: $('#content'),
+ model: new XBlockInfo(XBlockInfoJson, {parse: true}),
+ action: action,
+ templates: new ComponentTemplates(componentTemplates, {parse: true})
+ };
xmoduleLoader.done(function () {
var view = new ContainerPage(_.extend(main_options, options));
diff --git a/cms/static/js/factories/library.js b/cms/static/js/factories/library.js
index e7834f60ef3c..76ac47413ddc 100644
--- a/cms/static/js/factories/library.js
+++ b/cms/static/js/factories/library.js
@@ -1,20 +1,21 @@
define([
- 'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/container',
- 'js/collections/component_template', 'xmodule', 'coffee/src/main',
+ 'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/paged_container',
+ 'js/views/library_container', 'js/collections/component_template', 'xmodule', 'coffee/src/main',
'xblock/cms.runtime.v1'
],
-function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
+function($, _, XBlockInfo, PagedContainerPage, LibraryContainerView, ComponentTemplates, xmoduleLoader) {
'use strict';
return function (componentTemplates, XBlockInfoJson, options) {
var main_options = {
el: $('#content'),
model: new XBlockInfo(XBlockInfoJson, {parse: true}),
templates: new ComponentTemplates(componentTemplates, {parse: true}),
- action: 'view'
+ action: 'view',
+ viewClass: LibraryContainerView
};
xmoduleLoader.done(function () {
- var view = new ContainerPage(_.extend(main_options, options));
+ var view = new PagedContainerPage(_.extend(main_options, options));
view.render();
});
};
diff --git a/cms/static/js/spec/views/library_container_spec.js b/cms/static/js/spec/views/paged_container_spec.js
similarity index 99%
rename from cms/static/js/spec/views/library_container_spec.js
rename to cms/static/js/spec/views/paged_container_spec.js
index 2d39cdc35819..524f88e552f7 100644
--- a/cms/static/js/spec/views/library_container_spec.js
+++ b/cms/static/js/spec/views/paged_container_spec.js
@@ -1,6 +1,6 @@
define([ "jquery", "underscore", "js/common_helpers/ajax_helpers", "URI", "js/models/xblock_info",
- "js/views/library_container", "js/views/paging_header", "js/views/paging_footer"],
- function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingContainer, PagingFooter) {
+ "js/views/paged_container", "js/views/paging_header", "js/views/paging_footer"],
+ function ($, _, AjaxHelpers, URI, XBlockInfo, PagedContainer, PagingHeader, PagingFooter) {
var htmlResponseTpl = _.template('' +
''
diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js
index d5ec6938dc25..ce862aac7d7f 100644
--- a/cms/static/js/spec/views/pages/container_spec.js
+++ b/cms/static/js/spec/views/pages/container_spec.js
@@ -1,7 +1,7 @@
define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_helpers",
"js/common_helpers/template_helpers", "js/spec_helpers/edit_helpers",
- "js/views/pages/container", "js/models/xblock_info", "jquery.simulate"],
- function ($, _, str, AjaxHelpers, TemplateHelpers, EditHelpers, ContainerPage, XBlockInfo) {
+ "js/views/pages/container", "js/views/pages/paged_container", "js/models/xblock_info"],
+ function ($, _, str, AjaxHelpers, TemplateHelpers, EditHelpers, ContainerPage, PagedContainerPage, XBlockInfo) {
function parameterized_suite(label, global_page_options, fixtures) {
describe(label + " ContainerPage", function () {
@@ -13,7 +13,8 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
mockBadContainerXBlockHtml = readFixtures('mock/mock-bad-javascript-container-xblock.underscore'),
mockBadXBlockContainerXBlockHtml = readFixtures('mock/mock-bad-xblock-container-xblock.underscore'),
mockUpdatedContainerXBlockHtml = readFixtures('mock/mock-updated-container-xblock.underscore'),
- mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
+ mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore'),
+ PageClass = fixtures.page;
beforeEach(function () {
var newDisplayName = 'New Display Name';
@@ -62,7 +63,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
templates: EditHelpers.mockComponentTemplates,
el: $('#content')
};
- return new ContainerPage(_.extend(options || {}, global_page_options, default_options));
+ return new PageClass(_.extend(options || {}, global_page_options, default_options));
};
renderContainerPage = function (test, html, options) {
@@ -273,7 +274,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
});
describe("xblock operations", function () {
- var getGroupElement, paginated,
+ var getGroupElement, paginated, getDeleteOffset,
NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A",
allComponentsInGroup = _.map(
_.range(NUM_COMPONENTS_PER_GROUP),
@@ -283,9 +284,13 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
);
paginated = function () {
- return containerPage.enable_paging;
+ return containerPage instanceof PagedContainerPage;
};
+ getDeleteOffset = function () {
+ // Paginated containers will make an additional AJAX request.
+ return paginated() ? 3 : 2;
+ };
getGroupElement = function () {
return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']");
@@ -316,8 +321,6 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
deleteComponent = function (componentIndex, requestOffset) {
clickDelete(componentIndex);
AjaxHelpers.respondWithJson(requests, {});
-
- // second to last request contains given component's id (to delete the component)
AjaxHelpers.expectJsonRequest(requests, 'DELETE',
'/xblock/locator-component-' + GROUP_TO_TEST + (componentIndex + 1),
null, requests.length - requestOffset);
@@ -329,8 +332,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
deleteComponentWithSuccess = function (componentIndex) {
var deleteOffset;
- deleteOffset = paginated() ? 3 : 2;
-
+ deleteOffset = getDeleteOffset();
deleteComponent(componentIndex, deleteOffset);
// verify the new list of components within the group
@@ -356,17 +358,12 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
});
it("can delete an xblock with broken JavaScript", function () {
+ var deleteOffset = getDeleteOffset();
renderContainerPage(this, mockBadContainerXBlockHtml);
containerPage.$('.delete-button').first().click();
EditHelpers.confirmPrompt(promptSpy);
AjaxHelpers.respondWithJson(requests, {});
- var deleteOffset;
- if (paginated()) {
- deleteOffset = 3;
- } else {
- deleteOffset = 2;
- }
// expect the second to last request to be a delete of the xblock
AjaxHelpers.expectJsonRequest(requests, 'DELETE', '/xblock/locator-broken-javascript',
null, requests.length - deleteOffset);
@@ -528,7 +525,7 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
});
describe('Template Picker', function () {
- var showTemplatePicker, verifyCreateHtmlComponent, call_count;
+ var showTemplatePicker, verifyCreateHtmlComponent;
showTemplatePicker = function () {
containerPage.$('.new-component .new-component-type a.multiple-templates')[0].click();
@@ -536,7 +533,6 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
verifyCreateHtmlComponent = function (test, templateIndex, expectedRequest) {
var xblockCount;
- // call_count = paginated() ? 18: 10;
renderContainerPage(test, mockContainerXBlockHtml);
showTemplatePicker();
xblockCount = containerPage.$('.studio-xblock-wrapper').length;
@@ -568,12 +564,17 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
}
parameterized_suite("Non paged",
- { enable_paging: false },
- { initial: 'mock/mock-container-xblock.underscore', add_response: 'mock/mock-xblock.underscore' }
+ { },
+ {
+ page: ContainerPage,
+ initial: 'mock/mock-container-xblock.underscore',
+ add_response: 'mock/mock-xblock.underscore'
+ }
);
parameterized_suite("Paged",
- { enable_paging: true, page_size: 42 },
+ { page_size: 42 },
{
+ page: PagedContainerPage,
initial: 'mock/mock-container-paged-xblock.underscore',
add_response: 'mock/mock-xblock-paged.underscore'
});
diff --git a/cms/static/js/views/container.js b/cms/static/js/views/container.js
index ec89208b4435..a99993fe5da3 100644
--- a/cms/static/js/views/container.js
+++ b/cms/static/js/views/container.js
@@ -9,6 +9,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
// child xblocks within the page.
requestToken: "",
+ new_child_view: 'reorderable_container_child_preview',
+
xblockReady: function () {
XBlockView.prototype.xblockReady.call(this);
var reorderableClass, reorderableContainer,
diff --git a/cms/static/js/views/library_container.js b/cms/static/js/views/library_container.js
index 7c48e83cee8f..ea09c69c8929 100644
--- a/cms/static/js/views/library_container.js
+++ b/cms/static/js/views/library_container.js
@@ -1,6 +1,5 @@
-define(["jquery", "underscore", "js/views/paged_container", "js/utils/module", "gettext", "js/views/feedback_notification",
- "js/views/paging_header", "js/views/paging_footer"],
- function ($, _, PagedContainerView) {
+define(["js/views/paged_container"],
+ function (PagedContainerView) {
// To be extended with Library-specific features later.
var LibraryContainerView = PagedContainerView;
return LibraryContainerView;
diff --git a/cms/static/js/views/paged_container.js b/cms/static/js/views/paged_container.js
index cd7590156a17..a8cd7aec3242 100644
--- a/cms/static/js/views/paged_container.js
+++ b/cms/static/js/views/paged_container.js
@@ -5,9 +5,13 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex
initialize: function(options){
var self = this;
ContainerView.prototype.initialize.call(this);
- this.page_size = this.options.page_size || 10;
- this.page_reload_callback = options.page_reload_callback || function () {};
- // emulating Backbone.paginator interface
+ this.page_size = this.options.page_size;
+ // Reference to the page model
+ this.page = options.page;
+ // XBlocks are rendered via Django views and templates rather than underscore templates, and so don't
+ // have a Backbone model for us to manipulate in a backbone collection. Here, we emulate the interface
+ // of backbone.paginator so that we can use the Paging Header and Footer with this page. As a
+ // consequence, however, we have to manipulate its members manually.
this.collection = {
currentPage: 0,
totalPages: 0,
@@ -15,18 +19,23 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex
sortDirection: "desc",
start: 0,
_size: 0,
-
- bind: function() {}, // no-op
+ // Paging header and footer expect this to be a Backbone model they can listen to for changes, but
+ // they cannot. Provide the bind function for them, but have it do nothing.
+ bind: function() {},
+ // size() on backbone collections shows how many objects are in the collection, or in the case
+ // of paginator, on the current page.
size: function() { return self.collection._size; }
};
},
+ new_child_view: 'container_child_preview',
+
render: function(options) {
- var eff_options = options || {};
- eff_options.page_number = typeof eff_options.page_number !== "undefined"
- ? eff_options.page_number
+ options = options || {};
+ options.page_number = typeof options.page_number !== "undefined"
+ ? options.page_number
: this.collection.currentPage;
- return this.renderPage(eff_options);
+ return this.renderPage(options);
},
renderPage: function(options){
@@ -43,16 +52,15 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex
success: function(fragment) {
self.handleXBlockFragment(fragment, options);
self.processPaging({ requested_page: options.page_number });
- // This is expected to render the add xblock components menu.
- self.page_reload_callback(self.$el)
+ self.page.renderAddXBlockComponents()
}
});
},
getRenderParameters: function(page_number) {
return {
- enable_paging: true,
page_size: this.page_size,
+ enable_paging: true,
page_number: page_number
};
},
@@ -67,6 +75,8 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex
},
processPaging: function(options){
+ // We have the Django template sneak us the pagination information,
+ // and we load it from a div here.
var $element = this.$el.find('.xblock-container-paging-parameters'),
total = $element.data('total'),
displayed = $element.data('displayed'),
@@ -82,6 +92,8 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex
},
processPagingHeaderAndFooter: function(){
+ // Rendering the container view detaches the header and footer from the DOM.
+ // It's just as easy to recreate them as it is to try to shove them back into the tree.
if (this.pagingHeader)
this.pagingHeader.undelegateEvents();
if (this.pagingFooter)
@@ -100,12 +112,6 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex
this.pagingFooter.render();
},
- xblockReady: function () {
- ContainerView.prototype.xblockReady.call(this);
-
- this.requestToken = this.$('div.xblock').first().data('request-token');
- },
-
refresh: function(block_added) {
if (block_added) {
this.collection.totalCount += 1;
@@ -150,7 +156,7 @@ define(["jquery", "underscore", "js/views/container", "js/utils/module", "gettex
},
sortDisplayName: function() {
- return "Date added"; // TODO add support for sorting
+ return gettext("Date added"); // TODO add support for sorting
}
});
diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js
index 771e9afb1b89..406e6e9b0354 100644
--- a/cms/static/js/views/pages/container.js
+++ b/cms/static/js/views/pages/container.js
@@ -3,10 +3,10 @@
* This page allows the user to understand and manipulate the xblock and its children.
*/
define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views/utils/view_utils",
- "js/views/container", "js/views/library_container", "js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock",
+ "js/views/container", "js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock",
"js/models/xblock_info", "js/views/xblock_string_field_editor", "js/views/pages/container_subviews",
"js/views/unit_outline", "js/views/utils/xblock_utils"],
- function ($, _, gettext, BasePage, ViewUtils, ContainerView, PagedContainerView, XBlockView, AddXBlockComponent,
+ function ($, _, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent,
EditXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews, UnitOutlineView,
XBlockUtils) {
'use strict';
@@ -25,12 +25,16 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
view: 'container_preview',
+ defaultViewClass: ContainerView,
+
+ // Overridable by subclasses-- determines whether the XBlock component
+ // addition menu is added on initialization. You may set this to false
+ // if your subclass handles it.
+ components_on_init: true,
+
initialize: function(options) {
BasePage.prototype.initialize.call(this, options);
- this.enable_paging = options.enable_paging || false;
- if (this.enable_paging) {
- this.page_size = options.page_size || 10;
- }
+ this.viewClass = options.viewClass || this.defaultViewClass;
this.nameEditor = new XBlockStringFieldEditor({
el: this.$('.wrapper-xblock-field'),
model: this.model
@@ -75,28 +79,18 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
}
},
- getXBlockView: function(){
- var self = this,
- parameters = {
- el: this.$('.wrapper-xblock'),
- model: this.model,
- view: this.view
- };
-
- if (this.enable_paging) {
- parameters = _.extend(parameters, {
- page_size: this.page_size,
- page_reload_callback: function($element) {
- self.renderAddXBlockComponents();
- }
- });
- return new PagedContainerView(parameters);
- }
- else {
- return new ContainerView(parameters);
+ getViewParameters: function () {
+ return {
+ el: this.$('.wrapper-xblock'),
+ model: this.model,
+ view: this.view
}
},
+ getXBlockView: function(){
+ return new this.viewClass(this.getViewParameters());
+ },
+
render: function(options) {
var self = this,
xblockView = this.xblockView,
@@ -120,7 +114,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
xblockView.notifyRuntime('page-shown', self);
// Render the add buttons. Paged containers should do this on their own.
- if (!self.enable_paging) {
+ if (self.components_on_init) {
// Render the add buttons
self.renderAddXBlockComponents();
}
@@ -277,7 +271,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
rootLocator = this.xblockView.model.id;
if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) {
this.render({refresh: true, block_added: block_added});
- } else if (parentElement.hasClass('reorderable-container') || this.enable_paging) {
+ } else if (parentElement.hasClass('reorderable-container')) {
this.refreshChildXBlock(xblockElement, block_added);
} else {
this.refreshXBlock(this.findXBlockElement(parentElement));
@@ -313,7 +307,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
});
temporaryView = new TemporaryXBlockView({
model: xblockInfo,
- view: 'reorderable_container_child_preview',
+ view: self.xblockView.new_child_view,
el: xblockElement
});
return temporaryView.render({
diff --git a/cms/static/js/views/pages/paged_container.js b/cms/static/js/views/pages/paged_container.js
new file mode 100644
index 000000000000..916bf3005e43
--- /dev/null
+++ b/cms/static/js/views/pages/paged_container.js
@@ -0,0 +1,36 @@
+/**
+ * PagedXBlockContainerPage is a variant of XBlockContainerPage that supports Pagination.
+ */
+define(["jquery", "underscore", "gettext", "js/views/pages/container", "js/views/paged_container"],
+ function ($, _, gettext, XBlockContainerPage, PagedContainerView) {
+ 'use strict';
+ var PagedXBlockContainerPage = XBlockContainerPage.extend({
+
+ defaultViewClass: PagedContainerView,
+ components_on_init: false,
+
+ initialize: function (options){
+ this.page_size = options.page_size || 10;
+ XBlockContainerPage.prototype.initialize.call(this, options);
+ },
+
+ getViewParameters: function () {
+ return _.extend(XBlockContainerPage.prototype.getViewParameters.call(this), {
+ page_size: this.page_size,
+ page: this
+ });
+ },
+
+ refreshXBlock: function(element, block_added) {
+ var xblockElement = this.findXBlockElement(element),
+ rootLocator = this.xblockView.model.id;
+ if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) {
+ this.render({refresh: true, block_added: block_added});
+ } else {
+ this.refreshChildXBlock(xblockElement, block_added);
+ }
+ }
+
+ });
+ return PagedXBlockContainerPage;
+ });
diff --git a/cms/static/js/views/paging_footer.js b/cms/static/js/views/paging_footer.js
index 5a42c1d03c92..4ec3501d4482 100644
--- a/cms/static/js/views/paging_footer.js
+++ b/cms/static/js/views/paging_footer.js
@@ -44,6 +44,8 @@ define(["underscore", "js/views/baseview"], function(_, BaseView) {
if (pageNumber <= 0) {
pageNumber = false;
}
+ // If we still have a page number by this point,
+ // and it's not the current page, load it.
if (pageNumber && pageNumber !== currentPage) {
view.setPage(pageNumber - 1);
}
diff --git a/cms/static/js/views/paging_mixin.js b/cms/static/js/views/paging_mixin.js
index d2c1700e5d64..16d518f856d4 100644
--- a/cms/static/js/views/paging_mixin.js
+++ b/cms/static/js/views/paging_mixin.js
@@ -1,5 +1,5 @@
-define(["jquery", "underscore"],
- function ($, _) {
+define([],
+ function () {
var PagedMixin = {
setPage: function (page) {
var self = this,
diff --git a/cms/templates/library.html b/cms/templates/library.html
index dc9baa5736c3..d367c333d270 100644
--- a/cms/templates/library.html
+++ b/cms/templates/library.html
@@ -25,7 +25,6 @@
${component_templates | n}, ${json.dumps(xblock_info) | n},
{
isUnitPage: false,
- enable_paging: true,
page_size: 10
}
);
diff --git a/common/lib/xmodule/xmodule/library_root_xblock.py b/common/lib/xmodule/xmodule/library_root_xblock.py
index 3118f9a25842..6a58cf1b1d18 100644
--- a/common/lib/xmodule/xmodule/library_root_xblock.py
+++ b/common/lib/xmodule/xmodule/library_root_xblock.py
@@ -50,8 +50,7 @@ def author_view(self, context):
def render_children(self, context, fragment, can_reorder=False, can_add=False): # pylint: disable=unused-argument
"""
- Renders the children of the module with HTML appropriate for Studio. If can_reorder is True,
- then the children will be rendered to support drag and drop.
+ Renders the children of the module with HTML appropriate for Studio. Reordering is not supported.
"""
contents = []
@@ -77,7 +76,7 @@ def render_children(self, context, fragment, can_reorder=False, can_add=False):
contents.append({
'id': unicode(child.location),
- 'content': rendered_child.content
+ 'content': rendered_child.content,
})
fragment.add_content(
diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py
index 5572b1a91e43..64f93f21167e 100644
--- a/common/test/acceptance/pages/studio/library.py
+++ b/common/test/acceptance/pages/studio/library.py
@@ -3,14 +3,14 @@
"""
from bok_choy.page_object import PageObject
-from selenium.webdriver.common.keys import Keys
+from ...pages.studio.pagination import PaginatedMixin
from .container import XBlockWrapper
from ...tests.helpers import disable_animations
from .utils import confirm_prompt, wait_for_notification
from . import BASE_URL
-class LibraryPage(PageObject):
+class LibraryPage(PageObject, PaginatedMixin):
"""
Library page in Studio
"""
@@ -75,58 +75,6 @@ def click_delete_button(self, xblock_id, confirm=True):
confirm_prompt(self) # this will also wait_for_notification()
self.wait_for_ajax()
- def nav_disabled(self, position, arrows=('next', 'previous')):
- """
- Verifies that pagination nav is disabled. Position can be 'top' or 'bottom'.
-
- To specify a specific arrow, pass an iterable with a single element, 'next' or 'previous'.
- """
- return all([
- self.q(css='nav.%s * a.%s-page-link.is-disabled' % (position, arrow))
- for arrow in arrows
- ])
-
- def move_back(self, position):
- """
- Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
- """
- self.q(css='nav.%s * a.previous-page-link' % position)[0].click()
- self.wait_until_ready()
-
- def move_forward(self, position):
- """
- Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
- """
- self.q(css='nav.%s * a.next-page-link' % position)[0].click()
- self.wait_until_ready()
-
- def revisit(self):
- """
- Visit the page's URL, instead of refreshing, so that a new state is created.
- """
- self.browser.get(self.browser.current_url)
- self.wait_until_ready()
-
- def go_to_page(self, number):
- """
- Enter a number into the page number input field, and then try to navigate to it.
- """
- page_input = self.q(css="#page-number-input")[0]
- page_input.click()
- page_input.send_keys(str(number))
- page_input.send_keys(Keys.RETURN)
- self.wait_until_ready()
-
- def check_page_unchanged(self, first_block_name):
- """
- Used to make sure that a page has not transitioned after a bogus number is given.
- """
- if not self.xblocks[0].name == first_block_name:
- return False
- if not self.q(css='#page-number-input')[0].get_attribute('value') == '':
- return False
- return True
-
def _get_xblocks(self):
"""
Create an XBlockWrapper for each XBlock div found on the page.
diff --git a/common/test/acceptance/pages/studio/pagination.py b/common/test/acceptance/pages/studio/pagination.py
new file mode 100644
index 000000000000..a976149c37dd
--- /dev/null
+++ b/common/test/acceptance/pages/studio/pagination.py
@@ -0,0 +1,62 @@
+"""
+Mixin to include for Paginated container pages
+"""
+from selenium.webdriver.common.keys import Keys
+
+
+class PaginatedMixin(object):
+ """
+ Mixin class used for paginated page tests.
+ """
+ def nav_disabled(self, position, arrows=('next', 'previous')):
+ """
+ Verifies that pagination nav is disabled. Position can be 'top' or 'bottom'.
+
+ `top` is the header, `bottom` is the footer.
+
+ To specify a specific arrow, pass an iterable with a single element, 'next' or 'previous'.
+ """
+ return all([
+ self.q(css='nav.%s * a.%s-page-link.is-disabled' % (position, arrow))
+ for arrow in arrows
+ ])
+
+ def move_back(self, position):
+ """
+ Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
+ """
+ self.q(css='nav.%s * a.previous-page-link' % position)[0].click()
+ self.wait_until_ready()
+
+ def move_forward(self, position):
+ """
+ Clicks one of the forward nav buttons. Position can be 'top' or 'bottom'.
+ """
+ self.q(css='nav.%s * a.next-page-link' % position)[0].click()
+ self.wait_until_ready()
+
+ def go_to_page(self, number):
+ """
+ Enter a number into the page number input field, and then try to navigate to it.
+ """
+ page_input = self.q(css="#page-number-input")[0]
+ page_input.click()
+ page_input.send_keys(str(number))
+ page_input.send_keys(Keys.RETURN)
+ self.wait_until_ready()
+
+ def get_page_number(self):
+ """
+ Returns the page number as the page represents it, in string form.
+ """
+ return self.q(css="span.current-page")[0].get_attribute('innerHTML')
+
+ def check_page_unchanged(self, first_block_name):
+ """
+ Used to make sure that a page has not transitioned after a bogus number is given.
+ """
+ if not self.xblocks[0].name == first_block_name:
+ return False
+ if not self.q(css='#page-number-input')[0].get_attribute('value') == '':
+ return False
+ return True
diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py
index 5529f3603293..491c9093d0fc 100644
--- a/common/test/acceptance/tests/studio/test_studio_library.py
+++ b/common/test/acceptance/tests/studio/test_studio_library.py
@@ -4,6 +4,7 @@
from ddt import ddt, data
from .base_studio_test import StudioLibraryTest
+from ...fixtures.course import XBlockFixtureDesc
from ...pages.studio.utils import add_component
from ...pages.studio.library import LibraryPage
@@ -137,109 +138,64 @@ def test_nav_present_but_disabled(self, position):
Scenario: Ensure that the navigation buttons aren't active when there aren't enough XBlocks.
Given that I have a library in Studio with no XBlocks
The Navigation buttons should be disabled.
- When I add 5 multiple Choice XBlocks
+ When I add a multiple choice problem
The Navigation buttons should be disabled.
"""
self.assertEqual(len(self.lib_page.xblocks), 0)
self.assertTrue(self.lib_page.nav_disabled(position))
- for _ in range(0, 5):
- add_component(self.lib_page, "problem", "Multiple Choice")
+ add_component(self.lib_page, "problem", "Multiple Choice")
self.assertTrue(self.lib_page.nav_disabled(position))
- @data('top', 'bottom')
- def test_nav_buttons(self, position):
+
+@ddt
+class LibraryNavigationTest(StudioLibraryTest):
+ """
+ Test common Navigation actions
+ """
+ def setUp(self): # pylint: disable=arguments-differ
"""
- Scenario: Ensure that the navigation buttons work.
- Given that I have a library in Studio with no XBlocks
- And I create 10 Multiple Choice XBlocks
- And I create 10 Checkbox XBlocks
- And I create 10 Dropdown XBlocks
- And I revisit the page
- The previous button should be disabled.
- The first XBlock should be a Multiple Choice XBlock
- Then if I hit the next button
- The first XBlock should be a Checkboxes XBlock
- Then if I hit the next button
- The first XBlock should be a Dropdown XBlock
- And the next button should be disabled
- Then if I hit the previous button
- The first XBlock should be an Checkboxes XBlock
- Then if I hit the previous button
- The first XBlock should be a Multipe Choice XBlock
- And the previous button should be disabled
+ Ensure a library exists and navigate to the library edit page.
"""
- self.assertEqual(len(self.lib_page.xblocks), 0)
- block_types = [('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown')]
- for block_type in block_types:
- for _ in range(0, 10):
- add_component(self.lib_page, *block_type)
-
- # Don't refresh, as that may contain additional state.
- self.lib_page.revisit()
-
- # Check forward navigation
- self.assertTrue(self.lib_page.nav_disabled(position, ['previous']))
- self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice')
- self.lib_page.move_forward(position)
- self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes')
- self.lib_page.move_forward(position)
- self.assertEqual(self.lib_page.xblocks[0].name, 'Dropdown')
- self.lib_page.nav_disabled(position, ['next'])
+ super(LibraryNavigationTest, self).setUp(is_staff=True)
+ self.lib_page = LibraryPage(self.browser, self.library_key)
+ self.lib_page.visit()
+ self.lib_page.wait_until_ready()
- # Check backward navigation
- self.lib_page.move_back(position)
- self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes')
- self.lib_page.move_back(position)
- self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice')
- self.assertTrue(self.lib_page.nav_disabled(position, ['previous']))
+ def populate_library_fixture(self, library_fixture):
+ """
+ Create four pages worth of XBlocks, and offset by one so each is named
+ after the number they should be in line by the user's perception.
+ """
+ # pylint: disable=attribute-defined-outside-init
+ self.blocks = [XBlockFixtureDesc('html', str(i)) for i in xrange(1, 41)]
+ library_fixture.add_children(*self.blocks)
def test_arbitrary_page_selection(self):
"""
Scenario: I can pick a specific page number of a Library at will.
- Given that I have a library in Studio with no XBlocks
- And I create 10 Multiple Choice XBlocks
- And I create 10 Checkboxes XBlocks
- And I create 10 Dropdown XBlocks
- And I create 10 Numerical Input XBlocks
- And I revisit the page
+ Given that I have a library in Studio with 40 XBlocks
When I go to the 3rd page
- The first XBlock should be a Dropdown XBlock
+ The first XBlock should be the 21st XBlock
When I go to the 4th Page
- The first XBlock should be a Numerical Input XBlock
+ The first XBlock should be the 31st XBlock
When I go to the 1st page
- The first XBlock should be a Multiple Choice XBlock
+ The first XBlock should be the 1st XBlock
When I go to the 2nd page
- The first XBlock should be a Checkboxes XBlock
+ The first XBlock should be the 11th XBlock
"""
- self.assertEqual(len(self.lib_page.xblocks), 0)
- block_types = [
- ('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown'),
- ('problem', 'Numerical Input'),
- ]
- for block_type in block_types:
- for _ in range(0, 10):
- add_component(self.lib_page, *block_type)
-
- # Don't refresh, as that may contain additional state.
- self.lib_page.revisit()
self.lib_page.go_to_page(3)
- self.assertEqual(self.lib_page.xblocks[0].name, 'Dropdown')
+ self.assertEqual(self.lib_page.xblocks[0].name, '21')
self.lib_page.go_to_page(4)
- self.assertEqual(self.lib_page.xblocks[0].name, 'Numerical Input')
+ self.assertEqual(self.lib_page.xblocks[0].name, '31')
self.lib_page.go_to_page(1)
- self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice')
+ self.assertEqual(self.lib_page.xblocks[0].name, '1')
self.lib_page.go_to_page(2)
- self.assertEqual(self.lib_page.xblocks[0].name, 'Checkboxes')
+ self.assertEqual(self.lib_page.xblocks[0].name, '11')
def test_bogus_page_selection(self):
"""
Scenario: I can't pick a nonsense page number of a Library
- Given that I have a library in Studio with no XBlocks
- And I create 10 Multiple Choice XBlocks
- And I create 10 Checkboxes XBlocks
- And I create 10 Dropdown XBlocks
- And I create 10 Numerical Input XBlocks
- And I revisit the page
+ Given that I have a library in Studio with 40 XBlocks
When I attempt to go to the 'a'th page
The input field will be cleared and no change of XBlocks will be made
When I attempt to visit the 5th page
@@ -249,22 +205,104 @@ def test_bogus_page_selection(self):
When I attempt to visit the 0th page
The input field will be cleared and no change of XBlocks will be made
"""
- self.assertEqual(len(self.lib_page.xblocks), 0)
- block_types = [
- ('problem', 'Multiple Choice'), ('problem', 'Checkboxes'), ('problem', 'Dropdown'),
- ('problem', 'Numerical Input'),
- ]
- for block_type in block_types:
- for _ in range(0, 10):
- add_component(self.lib_page, *block_type)
-
- self.lib_page.revisit()
- self.assertEqual(self.lib_page.xblocks[0].name, 'Multiple Choice')
+ self.assertEqual(self.lib_page.xblocks[0].name, '1')
self.lib_page.go_to_page('a')
- self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice'))
+ self.assertTrue(self.lib_page.check_page_unchanged('1'))
self.lib_page.go_to_page(-1)
- self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice'))
+ self.assertTrue(self.lib_page.check_page_unchanged('1'))
self.lib_page.go_to_page(5)
- self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice'))
+ self.assertTrue(self.lib_page.check_page_unchanged('1'))
self.lib_page.go_to_page(0)
- self.assertTrue(self.lib_page.check_page_unchanged('Multiple Choice'))
+ self.assertTrue(self.lib_page.check_page_unchanged('1'))
+
+ @data('top', 'bottom')
+ def test_nav_buttons(self, position):
+ """
+ Scenario: Ensure that the navigation buttons work.
+ Given that I have a library in Studio with 40 XBlocks
+ The previous button should be disabled.
+ The first XBlock should be the 1st XBlock
+ Then if I hit the next button
+ The first XBlock should be the 11th XBlock
+ Then if I hit the next button
+ The first XBlock should be the 21st XBlock
+ Then if I hit the next button
+ The first XBlock should be the 31st XBlock
+ And the next button should be disabled
+ Then if I hit the previous button
+ The first XBlock should be the 21st XBlock
+ Then if I hit the previous button
+ The first XBlock should be the 11th XBlock
+ Then if I hit the previous button
+ The first XBlock should be the 1st XBlock
+ And the previous button should be disabled
+ """
+ # Check forward navigation
+ self.assertTrue(self.lib_page.nav_disabled(position, ['previous']))
+ self.assertEqual(self.lib_page.xblocks[0].name, '1')
+ self.lib_page.move_forward(position)
+ self.assertEqual(self.lib_page.xblocks[0].name, '11')
+ self.lib_page.move_forward(position)
+ self.assertEqual(self.lib_page.xblocks[0].name, '21')
+ self.lib_page.move_forward(position)
+ self.assertEqual(self.lib_page.xblocks[0].name, '31')
+ self.lib_page.nav_disabled(position, ['next'])
+
+ # Check backward navigation
+ self.lib_page.move_back(position)
+ self.assertEqual(self.lib_page.xblocks[0].name, '21')
+ self.lib_page.move_back(position)
+ self.assertEqual(self.lib_page.xblocks[0].name, '11')
+ self.lib_page.move_back(position)
+ self.assertEqual(self.lib_page.xblocks[0].name, '1')
+ self.assertTrue(self.lib_page.nav_disabled(position, ['previous']))
+
+ def test_library_pagination(self):
+ """
+ Scenario: Ensure that adding several XBlocks to a library results in pagination.
+ Given that I have a library in Studio with 40 XBlocks
+ Then 10 are displayed
+ And the first XBlock will be the 1st one
+ And I'm on the 1st page
+ When I add 1 Multiple Choice XBlock
+ Then 1 XBlock will be displayed
+ And I'm on the 5th page
+ The first XBlock will be the newest one
+ When I delete that XBlock
+ Then 10 are displayed
+ And I'm on the 4th page
+ And the first XBlock is the 31st one
+ And the last XBlock is the 40th one.
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 10)
+ self.assertEqual(self.lib_page.get_page_number(), '1')
+ self.assertEqual(self.lib_page.xblocks[0].name, '1')
+ add_component(self.lib_page, "problem", "Multiple Choice")
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ self.assertEqual(self.lib_page.get_page_number(), '5')
+ self.assertEqual(self.lib_page.xblocks[0].name, "Multiple Choice")
+ self.lib_page.click_delete_button(self.lib_page.xblocks[0].locator)
+ self.assertEqual(len(self.lib_page.xblocks), 10)
+ self.assertEqual(self.lib_page.get_page_number(), '4')
+ self.assertEqual(self.lib_page.xblocks[0].name, '31')
+ self.assertEqual(self.lib_page.xblocks[-1].name, '40')
+
+ def test_delete_shifts_blocks(self):
+ """
+ Scenario: Ensure that removing an XBlock shifts other blocks back.
+ Given that I have a library in Studio with 40 XBlocks
+ Then 10 are displayed
+ And I will be on the first page
+ When I delete the third XBlock
+ There will be 10 displayed
+ And the first XBlock will be the first one
+ And the last XBlock will be the 11th one
+ And I will be on the first page
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 10)
+ self.assertEqual(self.lib_page.get_page_number(), '1')
+ self.lib_page.click_delete_button(self.lib_page.xblocks[2].locator, confirm=True)
+ self.assertEqual(len(self.lib_page.xblocks), 10)
+ self.assertEqual(self.lib_page.xblocks[0].name, '1')
+ self.assertEqual(self.lib_page.xblocks[-1].name, '11')
+ self.assertEqual(self.lib_page.get_page_number(), '1')
From 219eeb6a6d3e7090d22812f647615151d6f3e13f Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Tue, 28 Oct 2014 23:10:40 -0700
Subject: [PATCH 08/23] Library Content XModule
---
cms/envs/common.py | 1 +
common/lib/xmodule/setup.py | 1 +
.../xmodule/xmodule/library_content_module.py | 441 ++++++++++++++++++
.../xmodule/public/js/library_content_edit.js | 24 +
lms/djangoapps/courseware/models.py | 4 +
lms/templates/library-block-author-view.html | 17 +
lms/templates/staff_problem_info.html | 2 +-
7 files changed, 489 insertions(+), 1 deletion(-)
create mode 100644 common/lib/xmodule/xmodule/library_content_module.py
create mode 100644 common/lib/xmodule/xmodule/public/js/library_content_edit.js
create mode 100644 lms/templates/library-block-author-view.html
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 397620ef864b..c81a392134ce 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -743,6 +743,7 @@
'word_cloud',
'graphical_slider_tool',
'lti',
+ 'library_content',
# XBlocks from pmitros repos are prototypes. They should not be used
# except for edX Learning Sciences experiments on edge.edx.org without
# further work to make them robust, maintainable, finalize data formats,
diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py
index f0721e91a47f..f2b548efe9c0 100644
--- a/common/lib/xmodule/setup.py
+++ b/common/lib/xmodule/setup.py
@@ -11,6 +11,7 @@
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"html = xmodule.html_module:HtmlDescriptor",
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
+ "library_content = xmodule.library_content_module:LibraryContentDescriptor",
"error = xmodule.error_module:ErrorDescriptor",
"peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
"poll_question = xmodule.poll_module:PollDescriptor",
diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py
new file mode 100644
index 000000000000..092bc2a93117
--- /dev/null
+++ b/common/lib/xmodule/xmodule/library_content_module.py
@@ -0,0 +1,441 @@
+"""
+LibraryContent: The XBlock used to include blocks from a library in a course.
+"""
+from bson.objectid import ObjectId
+from collections import namedtuple
+from copy import copy
+import hashlib
+from .mako_module import MakoModuleDescriptor
+from opaque_keys.edx.locator import LibraryLocator
+import random
+from webob import Response
+from xblock.core import XBlock
+from xblock.fields import Scope, String, List, Integer, Boolean
+from xblock.fragment import Fragment
+from xmodule.modulestore.exceptions import ItemNotFoundError
+from xmodule.x_module import XModule, STUDENT_VIEW
+from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor
+from .xml_module import XmlDescriptor
+from pkg_resources import resource_string
+
+# Make '_' a no-op so we can scrape strings
+_ = lambda text: text
+
+
+def enum(**enums):
+ """ enum helper in lieu of enum34 """
+ return type('Enum', (), enums)
+
+
+class LibraryVersionReference(namedtuple("LibraryVersionReference", "library_id version")):
+ """
+ A reference to a specific library, with an optional version.
+ The version is used to find out when the LibraryContentXBlock was last
+ updated with the latest content from the library.
+
+ library_id is a LibraryLocator
+ version is an ObjectId or None
+ """
+ def __new__(cls, library_id, version=None):
+ # pylint: disable=super-on-old-class
+ if not isinstance(library_id, LibraryLocator):
+ library_id = LibraryLocator.from_string(library_id)
+ if library_id.version_guid:
+ assert (version is None) or (version == library_id.version_guid)
+ if not version:
+ version = library_id.version_guid
+ library_id = library_id.for_version(None)
+ if version and not isinstance(version, ObjectId):
+ version = ObjectId(version)
+ return super(LibraryVersionReference, cls).__new__(cls, library_id, version)
+
+ @staticmethod
+ def from_json(value):
+ """
+ Implement from_json to convert from JSON
+ """
+ return LibraryVersionReference(*value)
+
+ def to_json(self):
+ """
+ Implement to_json to convert value to JSON
+ """
+ # TODO: Is there anyway for an xblock to *store* an ObjectId as
+ # part of the List() field value?
+ return [unicode(self.library_id), unicode(self.version) if self.version else None] # pylint: disable=no-member
+
+
+class LibraryList(List):
+ """
+ Special List class for listing references to content libraries.
+ Is simply a list of LibraryVersionReference tuples.
+ """
+ def from_json(self, values):
+ """
+ Implement from_json to convert from JSON.
+
+ values might be a list of lists, or a list of strings
+ Normally the runtime gives us:
+ [[u'library-v1:ProblemX+PR0B', '5436ffec56c02c13806a4c1b'], ...]
+ But the studio editor gives us:
+ [u'library-v1:ProblemX+PR0B,5436ffec56c02c13806a4c1b', ...]
+ """
+ def parse(val):
+ """ Convert this list entry from its JSON representation """
+ if isinstance(val, basestring):
+ val = val.strip(' []')
+ parts = val.rsplit(',', 1)
+ val = [parts[0], parts[1] if len(parts) > 1 else None]
+ return LibraryVersionReference.from_json(val)
+ return [parse(v) for v in values]
+
+ def to_json(self, values):
+ """
+ Implement to_json to convert value to JSON
+ """
+ return [lvr.to_json() for lvr in values]
+
+
+class LibraryContentFields(object):
+ """
+ Fields for the LibraryContentModule.
+
+ Separated out for now because they need to be added to the module and the
+ descriptor.
+ """
+ # Please note the display_name of each field below is used in
+ # common/test/acceptance/pages/studio/overview.py:StudioLibraryContentXBlockEditModal
+ # to locate input elements - keep synchronized
+ display_name = String(
+ display_name=_("Display Name"),
+ help=_("Display name for this module"),
+ default="Library Content",
+ scope=Scope.settings,
+ )
+ source_libraries = LibraryList(
+ display_name=_("Libraries"),
+ help=_("Enter a library ID for each library from which you want to draw content."),
+ default=[],
+ scope=Scope.settings,
+ )
+ mode = String(
+ help=_("Determines how content is drawn from the library"),
+ default="random",
+ values=[
+ {"display_name": _("Choose n at random"), "value": "random"}
+ # Future addition: Choose a new random set of n every time the student refreshes the block, for self tests
+ # Future addition: manually selected blocks
+ ],
+ scope=Scope.settings,
+ )
+ max_count = Integer(
+ display_name=_("Count"),
+ help=_("Enter the number of components to display to each student."),
+ default=1,
+ scope=Scope.settings,
+ )
+ filters = String(default="") # TBD
+ has_score = Boolean(
+ display_name=_("Scored"),
+ help=_("Set this value to True if this module is either a graded assignment or a practice problem."),
+ default=False,
+ scope=Scope.settings,
+ )
+ selected = List(
+ # This is a list of (block_type, block_id) tuples used to record which random/first set of matching blocks was selected per user
+ default=[],
+ scope=Scope.user_state,
+ )
+ has_children = True
+
+
+def _get_library(modulestore, library_key):
+ """
+ Given a library key like "library-v1:ProblemX+PR0B", return the
+ 'library' XBlock with meta-information about the library.
+
+ Returns None on error.
+ """
+ if not isinstance(library_key, LibraryLocator):
+ library_key = LibraryLocator.from_string(library_key)
+ assert library_key.version_guid is None
+
+ # TODO: Is this too tightly coupled to split? May need to abstract this into a service
+ # provided by the CMS runtime.
+ try:
+ library = modulestore.get_library(library_key, remove_version=False)
+ except ItemNotFoundError:
+ return None
+ # We need to know the library's version so ensure it's set in library.location.library_key.version_guid
+ assert library.location.library_key.version_guid is not None
+ return library
+
+
+#pylint: disable=abstract-method
+class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
+ """
+ An XBlock whose children are chosen dynamically from a content library.
+ Can be used to create randomized assessments among other things.
+
+ Note: technically, all matching blocks from the content library are added
+ as children of this block, but only a subset of those children are shown to
+ any particular student.
+ """
+ def selected_children(self):
+ """
+ Returns a set() of block_ids indicating which of the possible children
+ have been selected to display to the current user.
+
+ This reads and updates the "selected" field, which has user_state scope.
+
+ Note: self.selected and the return value contain block_ids. To get
+ actual BlockUsageLocators, it is necessary to use self.children,
+ because the block_ids alone do not specify the block type.
+ """
+ if hasattr(self, "_selected_set"):
+ # Already done:
+ return self._selected_set # pylint: disable=access-member-before-definition
+ # Determine which of our children we will show:
+ selected = set(tuple(k) for k in self.selected) # set of (block_type, block_id) tuples
+ valid_block_keys = set([(c.block_type, c.block_id) for c in self.children]) # pylint: disable=no-member
+ # Remove any selected blocks that are no longer valid:
+ selected -= (selected - valid_block_keys)
+ # If max_count has been decreased, we may have to drop some previously selected blocks:
+ while len(selected) > self.max_count:
+ selected.pop()
+ # Do we have enough blocks now?
+ num_to_add = self.max_count - len(selected)
+ if num_to_add > 0:
+ # We need to select [more] blocks to display to this user:
+ if self.mode == "random":
+ pool = valid_block_keys - selected
+ num_to_add = min(len(pool), num_to_add)
+ selected |= set(random.sample(pool, num_to_add))
+ # We now have the correct n random children to show for this user.
+ else:
+ raise NotImplementedError("Unsupported mode.")
+ # Save our selections to the user state, to ensure consistency:
+ self.selected = list(selected) # TODO: this doesn't save from the LMS "Progress" page.
+ # Cache the results
+ self._selected_set = selected # pylint: disable=attribute-defined-outside-init
+ return selected
+
+ def _get_selected_child_blocks(self):
+ """
+ Generator returning XBlock instances of the children selected for the
+ current user.
+ """
+ for block_type, block_id in self.selected_children():
+ yield self.runtime.get_block(self.location.course_key.make_usage_key(block_type, block_id))
+
+ def student_view(self, context):
+ fragment = Fragment()
+ contents = []
+ child_context = {} if not context else copy(context)
+
+ for child in self._get_selected_child_blocks():
+ for displayable in child.displayable_items():
+ rendered_child = displayable.render(STUDENT_VIEW, child_context)
+ fragment.add_frag_resources(rendered_child)
+ contents.append({
+ 'id': displayable.location.to_deprecated_string(),
+ 'content': rendered_child.content
+ })
+
+ fragment.add_content(self.system.render_template('vert_module.html', {
+ 'items': contents,
+ 'xblock_context': context,
+ }))
+ return fragment
+
+ def author_view(self, context):
+ """
+ Renders the Studio views.
+ Normal studio view: displays library status and has an "Update" button.
+ Studio container view: displays a preview of all possible children.
+ """
+ fragment = Fragment()
+ root_xblock = context.get('root_xblock')
+ is_root = root_xblock and root_xblock.location == self.location
+
+ if is_root:
+ # User has clicked the "View" link. Show a preview of all possible children:
+ if self.children: # pylint: disable=no-member
+ self.render_children(context, fragment, can_reorder=False, can_add=False)
+ else:
+ fragment.add_content(u'
{}
'.format(
+ _('No matching content found in library, no library configured, or not yet loaded from library.')
+ ))
+ else:
+ # When shown on a unit page, don't show any sort of preview - just the status of this block.
+ LibraryStatus = enum( # pylint: disable=invalid-name
+ NONE=0, # no library configured
+ INVALID=1, # invalid configuration or library has been deleted/corrupted
+ OK=2, # library configured correctly and should be working fine
+ )
+ UpdateStatus = enum( # pylint: disable=invalid-name
+ CANNOT=0, # Cannot update - library is not set, invalid, deleted, etc.
+ NEEDED=1, # An update is needed - prompt the user to update
+ UP_TO_DATE=2, # No update necessary - library is up to date
+ )
+ library_names = []
+ library_status = LibraryStatus.OK
+ update_status = UpdateStatus.UP_TO_DATE
+ if self.source_libraries:
+ for library_key, version in self.source_libraries:
+ library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key)
+ if library is None:
+ library_status = LibraryStatus.INVALID
+ update_status = UpdateStatus.CANNOT
+ break
+ library_names.append(library.display_name)
+ latest_version = library.location.library_key.version_guid
+ if version is None or version != latest_version:
+ update_status = UpdateStatus.NEEDED
+ # else library is up to date.
+ else:
+ library_status = LibraryStatus.NONE
+ update_status = UpdateStatus.CANNOT
+ fragment.add_content(self.system.render_template('library-block-author-view.html', {
+ 'library_status': library_status,
+ 'LibraryStatus': LibraryStatus,
+ 'update_status': update_status,
+ 'UpdateStatus': UpdateStatus,
+ 'library_names': library_names,
+ 'max_count': self.max_count,
+ 'mode': self.mode,
+ 'num_children': len(self.children), # pylint: disable=no-member
+ }))
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js'))
+ fragment.initialize_js('LibraryContentAuthorView')
+ return fragment
+
+ def get_child_descriptors(self):
+ """
+ Return only the subset of our children relevant to the current student.
+ """
+ return list(self._get_selected_child_blocks())
+
+
+@XBlock.wants('user')
+class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDescriptor, StudioEditableDescriptor):
+ """
+ Descriptor class for LibraryContentModule XBlock.
+ """
+ module_class = LibraryContentModule
+ mako_template = 'widgets/metadata-edit.html'
+ js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
+ js_module_name = "VerticalDescriptor"
+
+ @XBlock.handler
+ def refresh_children(self, request, suffix): # pylint: disable=unused-argument
+ """
+ Refresh children:
+ This method is to be used when any of the libraries that this block
+ references have been updated. It will re-fetch all matching blocks from
+ the libraries, and copy them as children of this block. The children
+ will be given new block_ids, but the definition ID used should be the
+ exact same definition ID used in the library.
+
+ This method will update this block's 'source_libraries' field to store
+ the version number of the libraries used, so we easily determine if
+ this block is up to date or not.
+ """
+ user_id = self.runtime.service(self, 'user').user_id
+ root_children = []
+
+ store = self.system.modulestore
+ with store.bulk_operations(self.location.course_key):
+ # Currently, ALL children are essentially deleted and then re-added
+ # in a way that preserves their block_ids (and thus should preserve
+ # student data, grades, analytics, etc.)
+ # Once course-level field overrides are implemented, this will
+ # change to a more conservative implementation.
+
+ # First, delete all our existing children to avoid block_id conflicts when we add them:
+ for child in self.children: # pylint: disable=access-member-before-definition
+ store.delete_item(child, user_id)
+
+ # Now add all matching children, and record the library version we use:
+ new_libraries = []
+ for library_key, old_version in self.source_libraries: # pylint: disable=unused-variable
+ library = _get_library(self.system.modulestore, library_key) # pylint: disable=protected-access
+
+ def copy_children_recursively(from_block):
+ """
+ Internal method to copy blocks from the library recursively
+ """
+ new_children = []
+ for child_key in from_block.children:
+ child = store.get_item(child_key, depth=9)
+ # We compute a block_id for each matching child block found in the library.
+ # block_ids are unique within any branch, but are not unique per-course or globally.
+ # We need our block_ids to be consistent when content in the library is updated, so
+ # we compute block_id as a hash of three pieces of data:
+ unique_data = "{}:{}:{}".format(
+ self.location.block_id, # Must not clash with other usages of the same library in this course
+ unicode(library_key.for_version(None)).encode("utf-8"), # The block ID below is only unique within a library, so we need this too
+ child_key.block_id, # Child block ID. Should not change even if the block is edited.
+ )
+ child_block_id = hashlib.sha1(unique_data).hexdigest()[:20]
+ fields = {}
+ for field in child.fields.itervalues():
+ if field.scope == Scope.settings and field.is_set_on(child):
+ fields[field.name] = field.read_from(child)
+ if child.has_children:
+ fields['children'] = copy_children_recursively(from_block=child)
+ new_child_info = store.create_item(
+ user_id,
+ self.location.course_key,
+ child_key.block_type,
+ block_id=child_block_id,
+ definition_locator=child.definition_locator,
+ runtime=self.system,
+ fields=fields,
+ )
+ new_children.append(new_child_info.location)
+ return new_children
+ root_children.extend(copy_children_recursively(from_block=library))
+ new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid))
+ self.source_libraries = new_libraries
+ self.children = root_children # pylint: disable=attribute-defined-outside-init
+ self.system.modulestore.update_item(self, user_id)
+ return Response()
+
+ def has_dynamic_children(self):
+ """
+ Inform the runtime that our children vary per-user.
+ See get_child_descriptors() above
+ """
+ return True
+
+ def get_content_titles(self):
+ """
+ Returns list of friendly titles for our selected children only; without
+ thi, all possible children's titles would be seen in the sequence bar in
+ the LMS.
+
+ This overwrites the get_content_titles method included in x_module by default.
+ """
+ titles = []
+ for child in self._xmodule.get_child_descriptors():
+ titles.extend(child.get_content_titles())
+ return titles
+
+ @classmethod
+ def definition_from_xml(cls, xml_object, system):
+ """ XML support not yet implemented. """
+ raise NotImplementedError
+
+ def definition_to_xml(self, resource_fs):
+ """ XML support not yet implemented. """
+ raise NotImplementedError
+
+ @classmethod
+ def from_xml(cls, xml_data, system, id_generator):
+ """ XML support not yet implemented. """
+ raise NotImplementedError
+
+ def export_to_xml(self, resource_fs):
+ """ XML support not yet implemented. """
+ raise NotImplementedError
diff --git a/common/lib/xmodule/xmodule/public/js/library_content_edit.js b/common/lib/xmodule/xmodule/public/js/library_content_edit.js
new file mode 100644
index 000000000000..9a84a214049c
--- /dev/null
+++ b/common/lib/xmodule/xmodule/public/js/library_content_edit.js
@@ -0,0 +1,24 @@
+/* JavaScript for editing operations that can be done on LibraryContentXBlock */
+window.LibraryContentAuthorView = function (runtime, element) {
+ $(element).find('.library-update-btn').on('click', function(e) {
+ e.preventDefault();
+ // Update the XBlock with the latest matching content from the library:
+ runtime.notify('save', {
+ state: 'start',
+ element: element,
+ message: gettext('Updating with latest library content')
+ });
+ $.post(runtime.handlerUrl(element, 'refresh_children')).done(function() {
+ runtime.notify('save', {
+ state: 'end',
+ element: element
+ });
+ // runtime.refreshXBlock(element);
+ // The above does not work, because this XBlock's runtime has no reference
+ // to the page (XBlockContainerPage). Only the Vertical XBlock's runtime has
+ // a reference to the page, and we have no way of getting a reference to it.
+ // So instead we:
+ location.reload();
+ });
+ });
+};
diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py
index 56818d4e2eea..d1f1f45b89ed 100644
--- a/lms/djangoapps/courseware/models.py
+++ b/lms/djangoapps/courseware/models.py
@@ -32,6 +32,10 @@ class StudentModule(models.Model):
MODULE_TYPES = (('problem', 'problem'),
('video', 'video'),
('html', 'html'),
+ ('course', 'course'),
+ ('chapter', 'Section'),
+ ('sequential', 'Subsection'),
+ ('library_content', 'Library Content'),
)
## These three are the key for the object
module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True)
diff --git a/lms/templates/library-block-author-view.html b/lms/templates/library-block-author-view.html
new file mode 100644
index 000000000000..521946a903db
--- /dev/null
+++ b/lms/templates/library-block-author-view.html
@@ -0,0 +1,17 @@
+<%!
+from django.utils.translation import ugettext as _
+%>
+
+ % if library_status == LibraryStatus.OK:
+
${_('This component will be replaced by {mode} {max_count} components from the {num_children} matching components from {lib_names}.').format(mode=mode, max_count=max_count, num_children=num_children, lib_names=', '.join(library_names))}
${_('No library or filters configured. Press "Edit" to configure.')}
+ % else:
+
${_('Library is invalid, corrupt, or has been deleted.')}
+ % endif
+
diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html
index 75d2789d7c6e..f486bfc6f65f 100644
--- a/lms/templates/staff_problem_info.html
+++ b/lms/templates/staff_problem_info.html
@@ -4,7 +4,7 @@
## The JS for this is defined in xqa_interface.html
${block_content}
-%if location.category in ['problem','video','html','combinedopenended','graphical_slider_tool']:
+%if location.category in ['problem','video','html','combinedopenended','graphical_slider_tool', 'library_content']:
% if edit_link:
Edit
From d2ae624ce752666937e6c29bb8da9a2119c577e8 Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Sat, 1 Nov 2014 19:47:28 -0700
Subject: [PATCH 09/23] Unit and integration tests of content libraries
---
.../contentstore/tests/test_libraries.py | 256 ++++++++++++++++++
.../xmodule/tests/test_library_content.py | 142 ++++++++++
2 files changed, 398 insertions(+)
create mode 100644 cms/djangoapps/contentstore/tests/test_libraries.py
create mode 100644 common/lib/xmodule/xmodule/tests/test_library_content.py
diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py
new file mode 100644
index 000000000000..b6c6119ed1b0
--- /dev/null
+++ b/cms/djangoapps/contentstore/tests/test_libraries.py
@@ -0,0 +1,256 @@
+"""
+Content library unit tests that require the CMS runtime.
+"""
+from contentstore.tests.utils import AjaxEnabledTestClient, parse_json
+from contentstore.utils import reverse_usage_url
+from contentstore.views.preview import _load_preview_module
+from contentstore.views.tests.test_library import LIBRARY_REST_URL
+import ddt
+from xmodule.library_content_module import LibraryVersionReference
+from xmodule.modulestore import ModuleStoreEnum
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.tests import get_test_system
+from mock import Mock
+from opaque_keys.edx.locator import CourseKey, LibraryLocator
+
+
+@ddt.ddt
+class TestLibraries(ModuleStoreTestCase):
+ """
+ High-level tests for libraries
+ """
+ def setUp(self):
+ user_password = super(TestLibraries, self).setUp()
+
+ self.client = AjaxEnabledTestClient()
+ self.client.login(username=self.user.username, password=user_password)
+
+ self.lib_key = self._create_library()
+ self.library = modulestore().get_library(self.lib_key)
+
+ def _create_library(self, org="org", library="lib", display_name="Test Library"):
+ """
+ Helper method used to create a library. Uses the REST API.
+ """
+ response = self.client.ajax_post(LIBRARY_REST_URL, {
+ 'org': org,
+ 'library': library,
+ 'display_name': display_name,
+ })
+ self.assertEqual(response.status_code, 200)
+ lib_info = parse_json(response)
+ lib_key = CourseKey.from_string(lib_info['library_key'])
+ self.assertIsInstance(lib_key, LibraryLocator)
+ return lib_key
+
+ def _add_library_content_block(self, course, library_key, other_settings=None):
+ """
+ Helper method to add a LibraryContent block to a course.
+ The block will be configured to select content from the library
+ specified by library_key.
+ other_settings can be a dict of Scope.settings fields to set on the block.
+ """
+ return ItemFactory.create(
+ category='library_content',
+ parent_location=course.location,
+ user_id=self.user.id,
+ publish_item=False,
+ source_libraries=[LibraryVersionReference(library_key)],
+ **(other_settings or {})
+ )
+
+ def _refresh_children(self, lib_content_block):
+ """
+ Helper method: Uses the REST API to call the 'refresh_children' handler
+ of a LibraryContent block
+ """
+ if 'user' not in lib_content_block.runtime._services: # pylint: disable=protected-access
+ lib_content_block.runtime._services['user'] = Mock(user_id=self.user.id) # pylint: disable=protected-access
+ handler_url = reverse_usage_url('component_handler', lib_content_block.location, kwargs={'handler': 'refresh_children'})
+ response = self.client.ajax_post(handler_url)
+ self.assertEqual(response.status_code, 200)
+ return modulestore().get_item(lib_content_block.location)
+
+ @ddt.data(
+ (2, 1, 1),
+ (2, 2, 2),
+ (2, 20, 2),
+ )
+ @ddt.unpack
+ def test_max_items(self, num_to_create, num_to_select, num_expected):
+ """
+ Test the 'max_count' property of LibraryContent blocks.
+ """
+ for _ in range(0, num_to_create):
+ ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False)
+
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ lc_block = self._add_library_content_block(course, self.lib_key, {'max_count': num_to_select})
+ self.assertEqual(len(lc_block.children), 0)
+ lc_block = self._refresh_children(lc_block)
+
+ # Now, we want to make sure that .children has the total # of potential
+ # children, and that get_child_descriptors() returns the actual children
+ # chosen for a given student.
+ # In order to be able to call get_child_descriptors(), we must first
+ # call bind_for_student:
+ lc_block.bind_for_student(get_test_system(), lc_block._field_data) # pylint: disable=protected-access
+ self.assertEqual(len(lc_block.children), num_to_create)
+ self.assertEqual(len(lc_block.get_child_descriptors()), num_expected)
+
+ def test_consistent_children(self):
+ """
+ Test that the same student will always see the same selected child block
+ """
+ session_data = {}
+
+ def bind_module(descriptor):
+ """
+ Helper to use the CMS's module system so we can access student-specific fields.
+ """
+ request = Mock(user=self.user, session=session_data)
+ return _load_preview_module(request, descriptor) # pylint: disable=protected-access
+
+ # Create many blocks in the library and add them to a course:
+ for num in range(0, 8):
+ ItemFactory.create(
+ data="This is #{}".format(num + 1),
+ category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False
+ )
+
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ lc_block = self._add_library_content_block(course, self.lib_key, {'max_count': 1})
+ lc_block_key = lc_block.location
+ lc_block = self._refresh_children(lc_block)
+
+ def get_child_of_lc_block(block):
+ """
+ Fetch the child shown to the current user.
+ """
+ children = block.get_child_descriptors()
+ self.assertEqual(len(children), 1)
+ return children[0]
+
+ # Check which child a student will see:
+ bind_module(lc_block)
+ chosen_child = get_child_of_lc_block(lc_block)
+ chosen_child_defn_id = chosen_child.definition_locator.definition_id
+ lc_block.save()
+
+ modulestore().update_item(lc_block, self.user.id)
+
+ # Now re-load the block and try again:
+ def check():
+ """
+ Confirm that chosen_child is still the child seen by the test student
+ """
+ for _ in range(0, 6): # Repeat many times b/c blocks are randomized
+ lc_block = modulestore().get_item(lc_block_key) # Reload block from the database
+ bind_module(lc_block)
+ current_child = get_child_of_lc_block(lc_block)
+ self.assertEqual(current_child.location, chosen_child.location)
+ self.assertEqual(current_child.data, chosen_child.data)
+ self.assertEqual(current_child.definition_locator.definition_id, chosen_child_defn_id)
+
+ check()
+ # Refresh the children:
+ lc_block = self._refresh_children(lc_block)
+ # Now re-load the block and try yet again, in case refreshing the children changed anything:
+ check()
+
+ def test_definition_shared_with_library(self):
+ """
+ Test that the same block definition is used for the library and course[s]
+ """
+ block1 = ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False)
+ def_id1 = block1.definition_locator.definition_id
+ block2 = ItemFactory.create(category="html", parent_location=self.library.location, user_id=self.user.id, publish_item=False)
+ def_id2 = block2.definition_locator.definition_id
+ self.assertNotEqual(def_id1, def_id2)
+
+ # Next, create a course:
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ # Add a LibraryContent block to the course:
+ lc_block = self._add_library_content_block(course, self.lib_key)
+ lc_block = self._refresh_children(lc_block)
+ for child_key in lc_block.children:
+ child = modulestore().get_item(child_key)
+ def_id = child.definition_locator.definition_id
+ self.assertIn(def_id, (def_id1, def_id2))
+
+ def test_fields(self):
+ """
+ Test that blocks used from a library have the same field values as
+ defined by the library author.
+ """
+ data_value = "A Scope.content value"
+ name_value = "A Scope.settings value"
+ lib_block = ItemFactory.create(
+ category="html",
+ parent_location=self.library.location,
+ user_id=self.user.id,
+ publish_item=False,
+ display_name=name_value,
+ data=data_value,
+ )
+ self.assertEqual(lib_block.data, data_value)
+ self.assertEqual(lib_block.display_name, name_value)
+
+ # Next, create a course:
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ # Add a LibraryContent block to the course:
+ lc_block = self._add_library_content_block(course, self.lib_key)
+ lc_block = self._refresh_children(lc_block)
+ course_block = modulestore().get_item(lc_block.children[0])
+
+ self.assertEqual(course_block.data, data_value)
+ self.assertEqual(course_block.display_name, name_value)
+
+ def test_block_with_children(self):
+ """
+ Test that blocks used from a library can have children.
+ """
+ data_value = "A Scope.content value"
+ name_value = "A Scope.settings value"
+ # In the library, create a vertical block with a child:
+ vert_block = ItemFactory.create(
+ category="vertical",
+ parent_location=self.library.location,
+ user_id=self.user.id,
+ publish_item=False,
+ )
+ child_block = ItemFactory.create(
+ category="html",
+ parent_location=vert_block.location,
+ user_id=self.user.id,
+ publish_item=False,
+ display_name=name_value,
+ data=data_value,
+ )
+ self.assertEqual(child_block.data, data_value)
+ self.assertEqual(child_block.display_name, name_value)
+
+ # Next, create a course:
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ # Add a LibraryContent block to the course:
+ lc_block = self._add_library_content_block(course, self.lib_key)
+ lc_block = self._refresh_children(lc_block)
+ self.assertEqual(len(lc_block.children), 1)
+ course_vert_block = modulestore().get_item(lc_block.children[0])
+ self.assertEqual(len(course_vert_block.children), 1)
+ course_child_block = modulestore().get_item(course_vert_block.children[0])
+
+ self.assertEqual(course_child_block.data, data_value)
+ self.assertEqual(course_child_block.display_name, name_value)
diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py
new file mode 100644
index 000000000000..2b52386e3740
--- /dev/null
+++ b/common/lib/xmodule/xmodule/tests/test_library_content.py
@@ -0,0 +1,142 @@
+# -*- coding: utf-8 -*-
+"""
+Basic unit tests for LibraryContentModule
+
+Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
+"""
+import ddt
+from xmodule.library_content_module import LibraryVersionReference
+from xmodule.modulestore.tests.factories import LibraryFactory, CourseFactory, ItemFactory
+from xmodule.modulestore.tests.utils import MixedSplitTestCase
+from xmodule.tests import get_test_system
+from xmodule.validation import StudioValidationMessage
+
+
+@ddt.ddt
+class TestLibraries(MixedSplitTestCase):
+ """
+ Basic unit tests for LibraryContentModule (library_content_module.py)
+ """
+ def setUp(self):
+ super(TestLibraries, self).setUp()
+
+ self.library = LibraryFactory.create(modulestore=self.store)
+ self.lib_blocks = [
+ ItemFactory.create(
+ category="html",
+ parent_location=self.library.location,
+ user_id=self.user_id,
+ publish_item=False,
+ metadata={"data": "Hello world from block {}".format(i), },
+ modulestore=self.store,
+ )
+ for i in range(1, 5)
+ ]
+ self.course = CourseFactory.create(modulestore=self.store)
+ self.chapter = ItemFactory.create(
+ category="chapter",
+ parent_location=self.course.location,
+ user_id=self.user_id,
+ modulestore=self.store,
+ )
+ self.sequential = ItemFactory.create(
+ category="sequential",
+ parent_location=self.chapter.location,
+ user_id=self.user_id,
+ modulestore=self.store,
+ )
+ self.vertical = ItemFactory.create(
+ category="vertical",
+ parent_location=self.sequential.location,
+ user_id=self.user_id,
+ modulestore=self.store,
+ )
+ self.lc_block = ItemFactory.create(
+ category="library_content",
+ parent_location=self.vertical.location,
+ user_id=self.user_id,
+ modulestore=self.store,
+ metadata={
+ 'max_count': 1,
+ 'source_libraries': [LibraryVersionReference(self.library.location.library_key)]
+ }
+ )
+
+ def _bind_course_module(self, module):
+ """
+ Bind a module (part of self.course) so we can access student-specific data.
+ """
+ module_system = get_test_system(course_id=self.course.location.course_key)
+ module_system.descriptor_runtime = module.runtime
+
+ def get_module(descriptor):
+ """Mocks module_system get_module function"""
+ sub_module_system = get_test_system(course_id=self.course.location.course_key)
+ sub_module_system.get_module = get_module
+ sub_module_system.descriptor_runtime = descriptor.runtime
+ descriptor.bind_for_student(sub_module_system, descriptor._field_data) # pylint: disable=protected-access
+ return descriptor
+
+ module_system.get_module = get_module
+ module.xmodule_runtime = module_system
+
+ def test_lib_content_block(self):
+ """
+ Test that blocks from a library are copied and added as children
+ """
+ # Check that the LibraryContent block has no children initially
+ # Normally the children get added when the "source_libraries" setting
+ # is updated, but the way we do it through a factory doesn't do that.
+ self.assertEqual(len(self.lc_block.children), 0)
+ # Update the LibraryContent module:
+ self.lc_block.refresh_children(None, None)
+ # Check that all blocks from the library are now children of the block:
+ self.assertEqual(len(self.lc_block.children), len(self.lib_blocks))
+
+ def test_children_seen_by_a_user(self):
+ """
+ Test that each student sees only one block as a child of the LibraryContent block.
+ """
+ self.lc_block.refresh_children(None, None)
+ self.lc_block = self.store.get_item(self.lc_block.location)
+ self._bind_course_module(self.lc_block)
+ # Make sure the runtime knows that the block's children vary per-user:
+ self.assertTrue(self.lc_block.has_dynamic_children())
+
+ self.assertEqual(len(self.lc_block.children), len(self.lib_blocks))
+
+ # Check how many children each user will see:
+ self.assertEqual(len(self.lc_block.get_child_descriptors()), 1)
+ # Check that get_content_titles() doesn't return titles for hidden/unused children
+ self.assertEqual(len(self.lc_block.get_content_titles()), 1)
+
+ def test_validation(self):
+ """
+ Test that the validation method of LibraryContent blocks is working.
+ """
+ # When source_libraries is blank, the validation summary should say this block needs to be configured:
+ self.lc_block.source_libraries = []
+ result = self.lc_block.validate()
+ self.assertFalse(result) # Validation fails due to at least one warning/message
+ self.assertTrue(result.summary)
+ self.assertEqual(StudioValidationMessage.NOT_CONFIGURED, result.summary.type)
+
+ # When source_libraries references a non-existent library, we should get an error:
+ self.lc_block.source_libraries = [LibraryVersionReference("library-v1:BAD+WOLF")]
+ result = self.lc_block.validate()
+ self.assertFalse(result) # Validation fails due to at least one warning/message
+ self.assertTrue(result.summary)
+ self.assertEqual(StudioValidationMessage.ERROR, result.summary.type)
+ self.assertIn("invalid", result.summary.text)
+
+ # When source_libraries is set but the block needs to be updated, the summary should say so:
+ self.lc_block.source_libraries = [LibraryVersionReference(self.library.location.library_key)]
+ result = self.lc_block.validate()
+ self.assertFalse(result) # Validation fails due to at least one warning/message
+ self.assertTrue(result.summary)
+ self.assertEqual(StudioValidationMessage.WARNING, result.summary.type)
+ self.assertIn("out of date", result.summary.text)
+
+ # Now if we update the block, all validation should pass:
+ self.lc_block.refresh_children(None, None)
+ self.assertTrue(self.lc_block.validate())
From d1681d05b4b596140d01c7ef635817f22644be56 Mon Sep 17 00:00:00 2001
From: "E. Kolpakov"
Date: Mon, 8 Dec 2014 15:16:42 +0700
Subject: [PATCH 10/23] LibraryContent bok choy acceptance tests
---
.../xmodule/xmodule/library_content_module.py | 1 +
common/test/acceptance/fixtures/course.py | 2 -
common/test/acceptance/fixtures/library.py | 1 +
common/test/acceptance/pages/lms/library.py | 37 ++++
.../test/acceptance/pages/studio/library.py | 179 +++++++++++++++++-
.../test/acceptance/tests/lms/test_library.py | 169 +++++++++++++++++
.../tests/studio/base_studio_test.py | 6 +-
.../tests/studio/test_studio_library.py | 4 +-
.../studio/test_studio_library_container.py | 133 +++++++++++++
9 files changed, 521 insertions(+), 11 deletions(-)
create mode 100644 common/test/acceptance/pages/lms/library.py
create mode 100644 common/test/acceptance/tests/lms/test_library.py
create mode 100644 common/test/acceptance/tests/studio/test_studio_library_container.py
diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py
index 092bc2a93117..2d1e386847b3 100644
--- a/common/lib/xmodule/xmodule/library_content_module.py
+++ b/common/lib/xmodule/xmodule/library_content_module.py
@@ -119,6 +119,7 @@ class LibraryContentFields(object):
scope=Scope.settings,
)
mode = String(
+ display_name=_("Mode"),
help=_("Determines how content is drawn from the library"),
default="random",
values=[
diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py
index 1e5bca8a337f..656a12a9658a 100644
--- a/common/test/acceptance/fixtures/course.py
+++ b/common/test/acceptance/fixtures/course.py
@@ -375,5 +375,3 @@ def _create_xblock_children(self, parent_loc, xblock_descriptions):
"""
super(CourseFixture, self)._create_xblock_children(parent_loc, xblock_descriptions)
self._publish_xblock(parent_loc)
-
-
diff --git a/common/test/acceptance/fixtures/library.py b/common/test/acceptance/fixtures/library.py
index f97b8e9fc222..5692c078dbd5 100644
--- a/common/test/acceptance/fixtures/library.py
+++ b/common/test/acceptance/fixtures/library.py
@@ -27,6 +27,7 @@ def __init__(self, org, number, display_name):
'display_name': display_name
}
+ self.display_name = display_name
self._library_key = None
super(LibraryFixture, self).__init__()
diff --git a/common/test/acceptance/pages/lms/library.py b/common/test/acceptance/pages/lms/library.py
new file mode 100644
index 000000000000..8655fae79f55
--- /dev/null
+++ b/common/test/acceptance/pages/lms/library.py
@@ -0,0 +1,37 @@
+"""
+Library Content XBlock Wrapper
+"""
+from bok_choy.page_object import PageObject
+
+
+class LibraryContentXBlockWrapper(PageObject):
+ """
+ A PageObject representing a wrapper around a LibraryContent block seen in the LMS
+ """
+ url = None
+ BODY_SELECTOR = '.xblock-student_view div'
+
+ def __init__(self, browser, locator):
+ super(LibraryContentXBlockWrapper, self).__init__(browser)
+ self.locator = locator
+
+ def is_browser_on_page(self):
+ return self.q(css='{}[data-id="{}"]'.format(self.BODY_SELECTOR, self.locator)).present
+
+ def _bounded_selector(self, selector):
+ """
+ Return `selector`, but limited to this particular block's context
+ """
+ return '{}[data-id="{}"] {}'.format(
+ self.BODY_SELECTOR,
+ self.locator,
+ selector
+ )
+
+ @property
+ def children_contents(self):
+ """
+ Gets contents of all child XBlocks as list of strings
+ """
+ child_blocks = self.q(css=self._bounded_selector("div[data-id]"))
+ return frozenset(child.text for child in child_blocks)
diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py
index 64f93f21167e..3151324cd079 100644
--- a/common/test/acceptance/pages/studio/library.py
+++ b/common/test/acceptance/pages/studio/library.py
@@ -3,8 +3,12 @@
"""
from bok_choy.page_object import PageObject
-from ...pages.studio.pagination import PaginatedMixin
+from bok_choy.promise import EmptyPromise
+from selenium.webdriver.common.keys import Keys
+from selenium.webdriver.support.select import Select
+from .overview import CourseOutlineModal
from .container import XBlockWrapper
+from ...pages.studio.pagination import PaginatedMixin
from ...tests.helpers import disable_animations
from .utils import confirm_prompt, wait_for_notification
from . import BASE_URL
@@ -48,7 +52,10 @@ def wait_until_ready(self):
for improved test reliability.
"""
self.wait_for_ajax()
- self.wait_for_element_invisibility('.ui-loading', 'Wait for the page to complete its initial loading of XBlocks via AJAX')
+ self.wait_for_element_invisibility(
+ '.ui-loading',
+ 'Wait for the page to complete its initial loading of XBlocks via AJAX'
+ )
disable_animations(self)
@property
@@ -80,14 +87,18 @@ def _get_xblocks(self):
Create an XBlockWrapper for each XBlock div found on the page.
"""
prefix = '.wrapper-xblock.level-page '
- return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map(lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
+ return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map(
+ lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))
+ ).results
def _div_for_xblock_id(self, xblock_id):
"""
Given an XBlock's usage locator as a string, return the WebElement for
that block's wrapper div.
"""
- return self.q(css='.wrapper-xblock.level-page .studio-xblock-wrapper').filter(lambda el: el.get_attribute('data-locator') == xblock_id)
+ return self.q(css='.wrapper-xblock.level-page .studio-xblock-wrapper').filter(
+ lambda el: el.get_attribute('data-locator') == xblock_id
+ )
def _action_btn_for_xblock_id(self, xblock_id, action):
"""
@@ -95,4 +106,162 @@ def _action_btn_for_xblock_id(self, xblock_id, action):
buttons.
action is 'edit', 'duplicate', or 'delete'
"""
- return self._div_for_xblock_id(xblock_id)[0].find_element_by_css_selector('.header-actions .{action}-button.action-button'.format(action=action))
+ return self._div_for_xblock_id(xblock_id)[0].find_element_by_css_selector(
+ '.header-actions .{action}-button.action-button'.format(action=action)
+ )
+
+
+class StudioLibraryContentXBlockEditModal(CourseOutlineModal, PageObject):
+ """
+ Library Content XBlock Modal edit window
+ """
+ url = None
+ MODAL_SELECTOR = ".wrapper-modal-window-edit-xblock"
+
+ # Labels used to identify the fields on the edit modal:
+ LIBRARY_LABEL = "Libraries"
+ COUNT_LABEL = "Count"
+ SCORED_LABEL = "Scored"
+
+ def is_browser_on_page(self):
+ """
+ Check that we are on the right page in the browser.
+ """
+ return self.is_shown()
+
+ @property
+ def library_key(self):
+ """
+ Gets value of first library key input
+ """
+ library_key_input = self.get_metadata_input(self.LIBRARY_LABEL)
+ if library_key_input is not None:
+ return library_key_input.get_attribute('value').strip(',')
+ return None
+
+ @library_key.setter
+ def library_key(self, library_key):
+ """
+ Sets value of first library key input, creating it if necessary
+ """
+ library_key_input = self.get_metadata_input(self.LIBRARY_LABEL)
+ if library_key_input is None:
+ library_key_input = self._add_library_key()
+ if library_key is not None:
+ # can't use lib_text.clear() here as input get deleted by client side script
+ library_key_input.send_keys(Keys.HOME)
+ library_key_input.send_keys(Keys.SHIFT, Keys.END)
+ library_key_input.send_keys(library_key)
+ else:
+ library_key_input.clear()
+ EmptyPromise(lambda: self.library_key == library_key, "library_key is updated in modal.").fulfill()
+
+ @property
+ def count(self):
+ """
+ Gets value of children count input
+ """
+ return int(self.get_metadata_input(self.COUNT_LABEL).get_attribute('value'))
+
+ @count.setter
+ def count(self, count):
+ """
+ Sets value of children count input
+ """
+ count_text = self.get_metadata_input(self.COUNT_LABEL)
+ count_text.clear()
+ count_text.send_keys(count)
+ EmptyPromise(lambda: self.count == count, "count is updated in modal.").fulfill()
+
+ @property
+ def scored(self):
+ """
+ Gets value of scored select
+ """
+ value = self.get_metadata_input(self.SCORED_LABEL).get_attribute('value')
+ if value == 'True':
+ return True
+ elif value == 'False':
+ return False
+ raise ValueError("Unknown value {value} set for {label}".format(value=value, label=self.SCORED_LABEL))
+
+ @scored.setter
+ def scored(self, scored):
+ """
+ Sets value of scored select
+ """
+ select_element = self.get_metadata_input(self.SCORED_LABEL)
+ select_element.click()
+ scored_select = Select(select_element)
+ scored_select.select_by_value(str(scored))
+ EmptyPromise(lambda: self.scored == scored, "scored is updated in modal.").fulfill()
+
+ def _add_library_key(self):
+ """
+ Adds library key input
+ """
+ wrapper = self._get_metadata_element(self.LIBRARY_LABEL)
+ add_button = wrapper.find_element_by_xpath(".//a[contains(@class, 'create-action')]")
+ add_button.click()
+ return self._get_list_inputs(wrapper)[0]
+
+ def _get_list_inputs(self, list_wrapper):
+ """
+ Finds nested input elements (useful for List and Dict fields)
+ """
+ return list_wrapper.find_elements_by_xpath(".//input[@type='text']")
+
+ def _get_metadata_element(self, metadata_key):
+ """
+ Gets metadata input element (a wrapper div for List and Dict fields)
+ """
+ metadata_inputs = self.find_css(".metadata_entry .wrapper-comp-setting label.setting-label")
+ target_label = [elem for elem in metadata_inputs if elem.text == metadata_key][0]
+ label_for = target_label.get_attribute('for')
+ return self.find_css("#" + label_for)[0]
+
+ def get_metadata_input(self, metadata_key):
+ """
+ Gets input/select element for given field
+ """
+ element = self._get_metadata_element(metadata_key)
+ if element.tag_name == 'div':
+ # List or Dict field - return first input
+ # TODO support multiple values
+ inputs = self._get_list_inputs(element)
+ element = inputs[0] if inputs else None
+ return element
+
+
+class StudioLibraryContainerXBlockWrapper(XBlockWrapper):
+ """
+ Wraps :class:`.container.XBlockWrapper` for use with LibraryContent blocks
+ """
+ url = None
+
+ @classmethod
+ def from_xblock_wrapper(cls, xblock_wrapper):
+ """
+ Factory method: creates :class:`.StudioLibraryContainerXBlockWrapper` from :class:`.container.XBlockWrapper`
+ """
+ return cls(xblock_wrapper.browser, xblock_wrapper.locator)
+
+ @property
+ def header_text(self):
+ """
+ Gets library content text
+ """
+ return self.get_body_paragraphs().first.text[0]
+
+ def get_body_paragraphs(self):
+ """
+ Gets library content body paragraphs
+ """
+ return self.q(css=self._bounded_selector(".xblock-message-area p"))
+
+ def refresh_children(self):
+ """
+ Click "Update now..." button
+ """
+ refresh_button = self.q(css=self._bounded_selector(".library-update-btn"))
+ refresh_button.click()
diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py
new file mode 100644
index 000000000000..78d699faa6fd
--- /dev/null
+++ b/common/test/acceptance/tests/lms/test_library.py
@@ -0,0 +1,169 @@
+# -*- coding: utf-8 -*-
+"""
+End-to-end tests for LibraryContent block in LMS
+"""
+import ddt
+
+from ..helpers import UniqueCourseTest
+from ...pages.studio.auto_auth import AutoAuthPage
+from ...pages.studio.overview import CourseOutlinePage
+from ...pages.studio.library import StudioLibraryContentXBlockEditModal, StudioLibraryContainerXBlockWrapper
+from ...pages.lms.courseware import CoursewarePage
+from ...pages.lms.library import LibraryContentXBlockWrapper
+from ...pages.common.logout import LogoutPage
+from ...fixtures.course import CourseFixture, XBlockFixtureDesc
+from ...fixtures.library import LibraryFixture
+
+SECTION_NAME = 'Test Section'
+SUBSECTION_NAME = 'Test Subsection'
+UNIT_NAME = 'Test Unit'
+
+
+@ddt.ddt
+class LibraryContentTest(UniqueCourseTest):
+ """
+ Test courseware.
+ """
+ USERNAME = "STUDENT_TESTER"
+ EMAIL = "student101@example.com"
+
+ STAFF_USERNAME = "STAFF_TESTER"
+ STAFF_EMAIL = "staff101@example.com"
+
+ def setUp(self):
+ """
+ Set up library, course and library content XBlock
+ """
+ super(LibraryContentTest, self).setUp()
+
+ self.courseware_page = CoursewarePage(self.browser, self.course_id)
+
+ self.course_outline = CourseOutlinePage(
+ self.browser,
+ self.course_info['org'],
+ self.course_info['number'],
+ self.course_info['run']
+ )
+
+ self.library_fixture = LibraryFixture('test_org', self.unique_id, 'Test Library {}'.format(self.unique_id))
+ self.library_fixture.add_children(
+ XBlockFixtureDesc("html", "Html1", data='html1'),
+ XBlockFixtureDesc("html", "Html2", data='html2'),
+ XBlockFixtureDesc("html", "Html3", data='html3'),
+ )
+
+ self.library_fixture.install()
+ self.library_info = self.library_fixture.library_info
+ self.library_key = self.library_fixture.library_key
+
+ # Install a course with library content xblock
+ self.course_fixture = CourseFixture(
+ self.course_info['org'], self.course_info['number'],
+ self.course_info['run'], self.course_info['display_name']
+ )
+
+ library_content_metadata = {
+ 'source_libraries': [self.library_key],
+ 'mode': 'random',
+ 'max_count': 1,
+ 'has_score': False
+ }
+
+ self.lib_block = XBlockFixtureDesc('library_content', "Library Content", metadata=library_content_metadata)
+
+ self.course_fixture.add_children(
+ XBlockFixtureDesc('chapter', SECTION_NAME).add_children(
+ XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children(
+ XBlockFixtureDesc('vertical', UNIT_NAME).add_children(
+ self.lib_block
+ )
+ )
+ )
+ )
+
+ self.course_fixture.install()
+
+ def _refresh_library_content_children(self, count=1):
+ """
+ Performs library block refresh in Studio, configuring it to show {count} children
+ """
+ unit_page = self._go_to_unit_page(True)
+ library_container_block = StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(unit_page.xblocks[0])
+ modal = StudioLibraryContentXBlockEditModal(library_container_block.edit())
+ modal.count = count
+ library_container_block.save_settings()
+ library_container_block.refresh_children()
+ self._go_to_unit_page(change_login=False)
+ unit_page.wait_for_page()
+ unit_page.publish_action.click()
+ unit_page.wait_for_ajax()
+ self.assertIn("Published and Live", unit_page.publish_title)
+
+ @property
+ def library_xblocks_texts(self):
+ """
+ Gets texts of all xblocks in library
+ """
+ return frozenset(child.data for child in self.library_fixture.children)
+
+ def _go_to_unit_page(self, change_login=True):
+ """
+ Open unit page in Studio
+ """
+ if change_login:
+ LogoutPage(self.browser).visit()
+ self._auto_auth(self.STAFF_USERNAME, self.STAFF_EMAIL, True)
+ self.course_outline.visit()
+ subsection = self.course_outline.section(SECTION_NAME).subsection(SUBSECTION_NAME)
+ return subsection.toggle_expand().unit(UNIT_NAME).go_to()
+
+ def _goto_library_block_page(self, block_id=None):
+ """
+ Open library page in LMS
+ """
+ self.courseware_page.visit()
+ block_id = block_id if block_id is not None else self.lib_block.locator
+ #pylint: disable=attribute-defined-outside-init
+ self.library_content_page = LibraryContentXBlockWrapper(self.browser, block_id)
+
+ def _auto_auth(self, username, email, staff):
+ """
+ Logout and login with given credentials.
+ """
+ AutoAuthPage(self.browser, username=username, email=email,
+ course_id=self.course_id, staff=staff).visit()
+
+ @ddt.data(1, 2, 3)
+ def test_shows_random_xblocks_from_configured(self, count):
+ """
+ Scenario: Ensures that library content shows {count} random xblocks from library in LMS
+ Given I have a library, a course and a LibraryContent block in that course
+ When I go to studio unit page for library content xblock as staff
+ And I set library content xblock to display {count} random children
+ And I refresh library content xblock and pulbish unit
+ When I go to LMS courseware page for library content xblock as student
+ Then I can see {count} random xblocks from the library
+ """
+ self._refresh_library_content_children(count=count)
+ self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self._goto_library_block_page()
+ children_contents = self.library_content_page.children_contents
+ self.assertEqual(len(children_contents), count)
+ self.assertLessEqual(children_contents, self.library_xblocks_texts)
+
+ def test_shows_all_if_max_set_to_greater_value(self):
+ """
+ Scenario: Ensures that library content shows {count} random xblocks from library in LMS
+ Given I have a library, a course and a LibraryContent block in that course
+ When I go to studio unit page for library content xblock as staff
+ And I set library content xblock to display more children than library have
+ And I refresh library content xblock and pulbish unit
+ When I go to LMS courseware page for library content xblock as student
+ Then I can see all xblocks from the library
+ """
+ self._refresh_library_content_children(count=10)
+ self._auto_auth(self.USERNAME, self.EMAIL, False)
+ self._goto_library_block_page()
+ children_contents = self.library_content_page.children_contents
+ self.assertEqual(len(children_contents), 3)
+ self.assertEqual(children_contents, self.library_xblocks_texts)
diff --git a/common/test/acceptance/tests/studio/base_studio_test.py b/common/test/acceptance/tests/studio/base_studio_test.py
index ec94f7f058c5..02fdcbe99849 100644
--- a/common/test/acceptance/tests/studio/base_studio_test.py
+++ b/common/test/acceptance/tests/studio/base_studio_test.py
@@ -109,8 +109,9 @@ class StudioLibraryTest(WebAppTest):
"""
Base class for all Studio library tests.
"""
+ as_staff = True
- def setUp(self, is_staff=False): # pylint: disable=arguments-differ
+ def setUp(self): # pylint: disable=arguments-differ
"""
Install a library with no content using a fixture.
"""
@@ -122,10 +123,11 @@ def setUp(self, is_staff=False): # pylint: disable=arguments-differ
)
self.populate_library_fixture(fixture)
fixture.install()
+ self.library_fixture = fixture
self.library_info = fixture.library_info
self.library_key = fixture.library_key
self.user = fixture.user
- self.log_in(self.user, is_staff)
+ self.log_in(self.user, self.as_staff)
def populate_library_fixture(self, library_fixture):
"""
diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py
index 491c9093d0fc..b0d6cffb1aed 100644
--- a/common/test/acceptance/tests/studio/test_studio_library.py
+++ b/common/test/acceptance/tests/studio/test_studio_library.py
@@ -18,7 +18,7 @@ def setUp(self): # pylint: disable=arguments-differ
"""
Ensure a library exists and navigate to the library edit page.
"""
- super(LibraryEditPageTest, self).setUp(is_staff=True)
+ super(LibraryEditPageTest, self).setUp()
self.lib_page = LibraryPage(self.browser, self.library_key)
self.lib_page.visit()
self.lib_page.wait_until_ready()
@@ -156,7 +156,7 @@ def setUp(self): # pylint: disable=arguments-differ
"""
Ensure a library exists and navigate to the library edit page.
"""
- super(LibraryNavigationTest, self).setUp(is_staff=True)
+ super(LibraryNavigationTest, self).setUp()
self.lib_page = LibraryPage(self.browser, self.library_key)
self.lib_page.visit()
self.lib_page.wait_until_ready()
diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py
new file mode 100644
index 000000000000..7bb712c779b5
--- /dev/null
+++ b/common/test/acceptance/tests/studio/test_studio_library_container.py
@@ -0,0 +1,133 @@
+"""
+Acceptance tests for Library Content in LMS
+"""
+import ddt
+from .base_studio_test import StudioLibraryTest, ContainerBase
+from ...pages.studio.library import StudioLibraryContentXBlockEditModal, StudioLibraryContainerXBlockWrapper
+from ...fixtures.course import XBlockFixtureDesc
+
+SECTION_NAME = 'Test Section'
+SUBSECTION_NAME = 'Test Subsection'
+UNIT_NAME = 'Test Unit'
+
+
+@ddt.ddt
+class StudioLibraryContainerTest(ContainerBase, StudioLibraryTest):
+ """
+ Test Library Content block in LMS
+ """
+ def setUp(self):
+ """
+ Install library with some content and a course using fixtures
+ """
+ super(StudioLibraryContainerTest, self).setUp()
+ self.outline.visit()
+ subsection = self.outline.section(SECTION_NAME).subsection(SUBSECTION_NAME)
+ self.unit_page = subsection.toggle_expand().unit(UNIT_NAME).go_to()
+
+ def populate_library_fixture(self, library_fixture):
+ """
+ Populate the children of the test course fixture.
+ """
+ library_fixture.add_children(
+ XBlockFixtureDesc("html", "Html1"),
+ XBlockFixtureDesc("html", "Html2"),
+ XBlockFixtureDesc("html", "Html3"),
+ )
+
+ def populate_course_fixture(self, course_fixture):
+ """ Install a course with sections/problems, tabs, updates, and handouts """
+ library_content_metadata = {
+ 'source_libraries': [self.library_key],
+ 'mode': 'random',
+ 'max_count': 1,
+ 'has_score': False
+ }
+
+ course_fixture.add_children(
+ XBlockFixtureDesc('chapter', SECTION_NAME).add_children(
+ XBlockFixtureDesc('sequential', SUBSECTION_NAME).add_children(
+ XBlockFixtureDesc('vertical', UNIT_NAME).add_children(
+ XBlockFixtureDesc('library_content', "Library Content", metadata=library_content_metadata)
+ )
+ )
+ )
+ )
+
+ def _get_library_xblock_wrapper(self, xblock):
+ """
+ Wraps xblock into :class:`...pages.studio.library.StudioLibraryContainerXBlockWrapper`
+ """
+ return StudioLibraryContainerXBlockWrapper.from_xblock_wrapper(xblock)
+
+ @ddt.data(
+ ('library-v1:111+111', 1, True),
+ ('library-v1:edX+L104', 2, False),
+ ('library-v1:OtherX+IDDQD', 3, True),
+ )
+ @ddt.unpack
+ def test_can_edit_metadata(self, library_key, max_count, scored):
+ """
+ Scenario: Given I have a library, a course and library content xblock in a course
+ When I go to studio unit page for library content block
+ And I edit library content metadata and save it
+ Then I can ensure that data is persisted
+ """
+ library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+ edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
+ edit_modal.library_key = library_key
+ edit_modal.count = max_count
+ edit_modal.scored = scored
+
+ library_container.save_settings() # saving settings
+
+ # open edit window again to verify changes are persistent
+ edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
+ self.assertEqual(edit_modal.library_key, library_key)
+ self.assertEqual(edit_modal.count, max_count)
+ self.assertEqual(edit_modal.scored, scored)
+
+ def test_no_library_shows_library_not_configured(self):
+ """
+ Scenario: Given I have a library, a course and library content xblock in a course
+ When I go to studio unit page for library content block
+ And I edit set library key to none
+ Then I can see that library content block is misconfigured
+ """
+ expected_text = 'No library or filters configured. Press "Edit" to configure.'
+ library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+
+ # precondition check - assert library is configured before we remove it
+ self.assertNotIn(expected_text, library_container.header_text)
+
+ edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
+ edit_modal.library_key = None
+
+ library_container.save_settings()
+
+ self.assertIn(expected_text, library_container.header_text)
+
+ @ddt.data(
+ 'library-v1:111+111',
+ 'library-v1:edX+L104',
+ )
+ def test_set_missing_library_shows_correct_label(self, library_key):
+ """
+ Scenario: Given I have a library, a course and library content xblock in a course
+ When I go to studio unit page for library content block
+ And I edit set library key to non-existent library
+ Then I can see that library content block is misconfigured
+ """
+ expected_text = "Library is invalid, corrupt, or has been deleted."
+
+ library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+
+ # precondition check - assert library is configured before we remove it
+ self.assertNotIn(expected_text, library_container.header_text)
+
+ edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
+ edit_modal.library_key = library_key
+
+ library_container.save_settings()
+
+ self.assertIn(expected_text, library_container.header_text)
From e312c15a8d91a1550df9ea970e7f23df008e17ba Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Wed, 10 Dec 2014 13:02:38 -0800
Subject: [PATCH 11/23] Friendly error message when library key is invalid
---
cms/djangoapps/contentstore/views/item.py | 5 ++--
.../contentstore/views/tests/test_item.py | 23 +++++++++++++++++++
.../xmodule/xmodule/library_content_module.py | 17 +++++++++++---
3 files changed, 40 insertions(+), 5 deletions(-)
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index c1f627009cd0..7b893486eedd 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -427,8 +427,9 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None,
else:
try:
value = field.from_json(value)
- except ValueError:
- return JsonResponse({"error": "Invalid data"}, 400)
+ except ValueError as verr:
+ reason = _("Invalid data ({details})").format(details=verr.message) if verr.message else _("Invalid data")
+ return JsonResponse({"error": reason}, 400)
field.write_to(xblock, value)
# update the xblock and call any xblock callbacks
diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py
index d6d913a59489..6b595ae00755 100644
--- a/cms/djangoapps/contentstore/views/tests/test_item.py
+++ b/cms/djangoapps/contentstore/views/tests/test_item.py
@@ -894,6 +894,29 @@ def test_publish_states_of_nested_xblocks(self):
self._verify_published_with_draft(unit_usage_key)
self._verify_published_with_draft(html_usage_key)
+ def test_field_value_errors(self):
+ """
+ Test that if the user's input causes a ValueError on an XBlock field,
+ we provide a friendly error message back to the user.
+ """
+ response = self.create_xblock(parent_usage_key=self.seq_usage_key, category='video')
+ video_usage_key = self.response_usage_key(response)
+ update_url = reverse_usage_url('xblock_handler', video_usage_key)
+
+ response = self.client.ajax_post(
+ update_url,
+ data={
+ 'id': unicode(video_usage_key),
+ 'metadata': {
+ 'saved_video_position': "Not a valid relative time",
+ },
+ }
+ )
+ self.assertEqual(response.status_code, 400)
+ parsed = json.loads(response.content)
+ self.assertIn("error", parsed)
+ self.assertIn("Incorrect RelativeTime value", parsed["error"]) # See xmodule/fields.py
+
class TestEditSplitModule(ItemTest):
"""
diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py
index 2d1e386847b3..4233429e0b2f 100644
--- a/common/lib/xmodule/xmodule/library_content_module.py
+++ b/common/lib/xmodule/xmodule/library_content_module.py
@@ -1,11 +1,12 @@
"""
LibraryContent: The XBlock used to include blocks from a library in a course.
"""
-from bson.objectid import ObjectId
+from bson.objectid import ObjectId, InvalidId
from collections import namedtuple
from copy import copy
import hashlib
from .mako_module import MakoModuleDescriptor
+from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import LibraryLocator
import random
from webob import Response
@@ -46,7 +47,10 @@ def __new__(cls, library_id, version=None):
version = library_id.version_guid
library_id = library_id.for_version(None)
if version and not isinstance(version, ObjectId):
- version = ObjectId(version)
+ try:
+ version = ObjectId(version)
+ except InvalidId:
+ raise ValueError(version)
return super(LibraryVersionReference, cls).__new__(cls, library_id, version)
@staticmethod
@@ -86,7 +90,14 @@ def parse(val):
val = val.strip(' []')
parts = val.rsplit(',', 1)
val = [parts[0], parts[1] if len(parts) > 1 else None]
- return LibraryVersionReference.from_json(val)
+ try:
+ return LibraryVersionReference.from_json(val)
+ except InvalidKeyError:
+ try:
+ friendly_val = val[0] # Just get the library key part, not the version
+ except IndexError:
+ friendly_val = unicode(val)
+ raise ValueError(_('"{value}" is not a valid library ID.').format(value=friendly_val))
return [parse(v) for v in values]
def to_json(self, values):
From eecc6f1032ab55d249d7c4289f9c5cf64caaa91e Mon Sep 17 00:00:00 2001
From: Jonathan Piacenti
Date: Wed, 26 Nov 2014 21:48:08 +0000
Subject: [PATCH 12/23] Added explanation to container view of Library Block.
---
common/lib/xmodule/xmodule/library_content_module.py | 7 ++++++-
lms/templates/library-block-author-preview-header.html | 10 ++++++++++
2 files changed, 16 insertions(+), 1 deletion(-)
create mode 100644 lms/templates/library-block-author-preview-header.html
diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py
index 4233429e0b2f..753d1938556a 100644
--- a/common/lib/xmodule/xmodule/library_content_module.py
+++ b/common/lib/xmodule/xmodule/library_content_module.py
@@ -251,7 +251,7 @@ def student_view(self, context):
fragment.add_frag_resources(rendered_child)
contents.append({
'id': displayable.location.to_deprecated_string(),
- 'content': rendered_child.content
+ 'content': rendered_child.content,
})
fragment.add_content(self.system.render_template('vert_module.html', {
@@ -273,6 +273,11 @@ def author_view(self, context):
if is_root:
# User has clicked the "View" link. Show a preview of all possible children:
if self.children: # pylint: disable=no-member
+ fragment.add_content(self.system.render_template("library-block-author-preview-header.html", {
+ 'max_count': self.max_count,
+ 'display_name': self.display_name or self.url_name,
+ 'mode': self.mode,
+ }))
self.render_children(context, fragment, can_reorder=False, can_add=False)
else:
fragment.add_content(u'
{}
'.format(
diff --git a/lms/templates/library-block-author-preview-header.html b/lms/templates/library-block-author-preview-header.html
new file mode 100644
index 000000000000..4596281b6702
--- /dev/null
+++ b/lms/templates/library-block-author-preview-header.html
@@ -0,0 +1,10 @@
+<%! from django.utils.translation import ugettext as _ %>
+
+
+
+
+ ${_('Showing all matching content eligible to be added into {display_name}. Each student will be assigned {mode} {max_count} components from this list.').format(max_count=max_count, display_name=display_name, mode=mode)}
+
+
+
+
From a7b577d1737daca865f29daff94578b7f058a0a2 Mon Sep 17 00:00:00 2001
From: Jonathan Piacenti
Date: Wed, 26 Nov 2014 23:57:16 +0000
Subject: [PATCH 13/23] Made errors on Library blocks use validate
functionality.
---
.../xmodule/xmodule/library_content_module.py | 77 ++++++++++++-------
lms/templates/library-block-author-view.html | 6 +-
2 files changed, 52 insertions(+), 31 deletions(-)
diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py
index 753d1938556a..476c97b0112d 100644
--- a/common/lib/xmodule/xmodule/library_content_module.py
+++ b/common/lib/xmodule/xmodule/library_content_module.py
@@ -14,6 +14,7 @@
from xblock.fields import Scope, String, List, Integer, Boolean
from xblock.fragment import Fragment
from xmodule.modulestore.exceptions import ItemNotFoundError
+from xmodule.validation import StudioValidationMessage, StudioValidation
from xmodule.x_module import XModule, STUDENT_VIEW
from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor
from .xml_module import XmlDescriptor
@@ -260,6 +261,40 @@ def student_view(self, context):
}))
return fragment
+ def validate(self):
+ """
+ Validates the state of this Library Content Module Instance. This
+ is the override of the general XBlock method, and it will also ask
+ its superclass to validate.
+ """
+ validation = super(LibraryContentModule, self).validate()
+ if not isinstance(validation, StudioValidation):
+ validation = StudioValidation.copy(validation)
+ if not self.source_libraries:
+ validation.set_summary(
+ StudioValidationMessage(
+ StudioValidationMessage.NOT_CONFIGURED,
+ _(u"A library has not yet been selected."),
+ action_class='edit-button',
+ action_label=_(u"Select a Library")
+ )
+ )
+ return validation
+ for library_key, version in self.source_libraries: # pylint: disable=unused-variable
+ library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key)
+ if library is None:
+ validation.set_summary(
+ StudioValidationMessage(
+ StudioValidationMessage.ERROR,
+ _(u'Library is invalid, corrupt, or has been deleted.'),
+ action_class='edit-button',
+ action_label=_(u"Edit Library List")
+ )
+ )
+ break
+
+ return validation
+
def author_view(self, context):
"""
Renders the Studio views.
@@ -284,41 +319,31 @@ def author_view(self, context):
_('No matching content found in library, no library configured, or not yet loaded from library.')
))
else:
- # When shown on a unit page, don't show any sort of preview - just the status of this block.
- LibraryStatus = enum( # pylint: disable=invalid-name
- NONE=0, # no library configured
- INVALID=1, # invalid configuration or library has been deleted/corrupted
- OK=2, # library configured correctly and should be working fine
- )
UpdateStatus = enum( # pylint: disable=invalid-name
CANNOT=0, # Cannot update - library is not set, invalid, deleted, etc.
NEEDED=1, # An update is needed - prompt the user to update
UP_TO_DATE=2, # No update necessary - library is up to date
)
+ # When shown on a unit page, don't show any sort of preview - just the status of this block.
+ library_ok = bool(self.source_libraries) # True if at least one source library is defined
library_names = []
- library_status = LibraryStatus.OK
update_status = UpdateStatus.UP_TO_DATE
- if self.source_libraries:
- for library_key, version in self.source_libraries:
- library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key)
- if library is None:
- library_status = LibraryStatus.INVALID
- update_status = UpdateStatus.CANNOT
- break
- library_names.append(library.display_name)
- latest_version = library.location.library_key.version_guid
- if version is None or version != latest_version:
- update_status = UpdateStatus.NEEDED
- # else library is up to date.
- else:
- library_status = LibraryStatus.NONE
- update_status = UpdateStatus.CANNOT
+ for library_key, version in self.source_libraries:
+ library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key)
+ if library is None:
+ update_status = UpdateStatus.CANNOT
+ library_ok = False
+ break
+ library_names.append(library.display_name)
+ latest_version = library.location.library_key.version_guid
+ if version is None or version != latest_version:
+ update_status = UpdateStatus.NEEDED
+
fragment.add_content(self.system.render_template('library-block-author-view.html', {
- 'library_status': library_status,
- 'LibraryStatus': LibraryStatus,
- 'update_status': update_status,
- 'UpdateStatus': UpdateStatus,
'library_names': library_names,
+ 'library_ok': library_ok,
+ 'UpdateStatus': UpdateStatus,
+ 'update_status': update_status,
'max_count': self.max_count,
'mode': self.mode,
'num_children': len(self.children), # pylint: disable=no-member
diff --git a/lms/templates/library-block-author-view.html b/lms/templates/library-block-author-view.html
index 521946a903db..ce1542cc5888 100644
--- a/lms/templates/library-block-author-view.html
+++ b/lms/templates/library-block-author-view.html
@@ -2,16 +2,12 @@
from django.utils.translation import ugettext as _
%>
- % if library_status == LibraryStatus.OK:
+ % if library_ok:
${_('This component will be replaced by {mode} {max_count} components from the {num_children} matching components from {lib_names}.').format(mode=mode, max_count=max_count, num_children=num_children, lib_names=', '.join(library_names))}
${_('No library or filters configured. Press "Edit" to configure.')}
- % else:
-
${_('Library is invalid, corrupt, or has been deleted.')}
% endif
From dfd0d70ab8209a57b88cf5c3155fbac25d1c4c6d Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Wed, 10 Dec 2014 14:08:51 -0800
Subject: [PATCH 14/23] Move update link to the validation area
---
.../xmodule/xmodule/library_content_module.py | 65 +++++++++----------
.../xmodule/public/js/library_content_edit.js | 12 +++-
.../test/acceptance/pages/studio/container.py | 39 +++++++++++
.../test/acceptance/pages/studio/library.py | 11 +---
.../studio/test_studio_library_container.py | 47 ++++++++++----
.../library-block-author-preview-header.html | 2 +-
lms/templates/library-block-author-view.html | 9 +--
7 files changed, 117 insertions(+), 68 deletions(-)
diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py
index 476c97b0112d..d9e28e93fd09 100644
--- a/common/lib/xmodule/xmodule/library_content_module.py
+++ b/common/lib/xmodule/xmodule/library_content_module.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
"""
LibraryContent: The XBlock used to include blocks from a library in a course.
"""
@@ -280,9 +281,21 @@ def validate(self):
)
)
return validation
- for library_key, version in self.source_libraries: # pylint: disable=unused-variable
+ for library_key, version in self.source_libraries:
library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key)
- if library is None:
+ if library is not None:
+ latest_version = library.location.library_key.version_guid
+ if version is None or version != latest_version:
+ validation.set_summary(
+ StudioValidationMessage(
+ StudioValidationMessage.WARNING,
+ _(u'This component is out of date. The library has new content.'),
+ action_class='library-update-btn', # TODO: change this to action_runtime_event='...' once the unit page supports that feature.
+ action_label=_(u"↻ Update now")
+ )
+ )
+ break
+ else:
validation.set_summary(
StudioValidationMessage(
StudioValidationMessage.ERROR,
@@ -298,7 +311,7 @@ def validate(self):
def author_view(self, context):
"""
Renders the Studio views.
- Normal studio view: displays library status and has an "Update" button.
+ Normal studio view: If block is properly configured, displays library status summary
Studio container view: displays a preview of all possible children.
"""
fragment = Fragment()
@@ -311,45 +324,25 @@ def author_view(self, context):
fragment.add_content(self.system.render_template("library-block-author-preview-header.html", {
'max_count': self.max_count,
'display_name': self.display_name or self.url_name,
- 'mode': self.mode,
}))
self.render_children(context, fragment, can_reorder=False, can_add=False)
- else:
- fragment.add_content(u'
{}
'.format(
- _('No matching content found in library, no library configured, or not yet loaded from library.')
- ))
else:
- UpdateStatus = enum( # pylint: disable=invalid-name
- CANNOT=0, # Cannot update - library is not set, invalid, deleted, etc.
- NEEDED=1, # An update is needed - prompt the user to update
- UP_TO_DATE=2, # No update necessary - library is up to date
- )
# When shown on a unit page, don't show any sort of preview - just the status of this block.
- library_ok = bool(self.source_libraries) # True if at least one source library is defined
library_names = []
- update_status = UpdateStatus.UP_TO_DATE
- for library_key, version in self.source_libraries:
+ for library_key, version in self.source_libraries: # pylint: disable=unused-variable
library = _get_library(self.runtime.descriptor_runtime.modulestore, library_key)
- if library is None:
- update_status = UpdateStatus.CANNOT
- library_ok = False
- break
- library_names.append(library.display_name)
- latest_version = library.location.library_key.version_guid
- if version is None or version != latest_version:
- update_status = UpdateStatus.NEEDED
-
- fragment.add_content(self.system.render_template('library-block-author-view.html', {
- 'library_names': library_names,
- 'library_ok': library_ok,
- 'UpdateStatus': UpdateStatus,
- 'update_status': update_status,
- 'max_count': self.max_count,
- 'mode': self.mode,
- 'num_children': len(self.children), # pylint: disable=no-member
- }))
- fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js'))
- fragment.initialize_js('LibraryContentAuthorView')
+ if library is not None:
+ library_names.append(library.display_name)
+
+ if library_names:
+ fragment.add_content(self.system.render_template('library-block-author-view.html', {
+ 'library_names': library_names,
+ 'max_count': self.max_count,
+ 'num_children': len(self.children), # pylint: disable=no-member
+ }))
+ # The following JS is used to make the "Update now" button work on the unit page and the container view:
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit.js'))
+ fragment.initialize_js('LibraryContentAuthorView')
return fragment
def get_child_descriptors(self):
diff --git a/common/lib/xmodule/xmodule/public/js/library_content_edit.js b/common/lib/xmodule/xmodule/public/js/library_content_edit.js
index 9a84a214049c..2db019feddf2 100644
--- a/common/lib/xmodule/xmodule/public/js/library_content_edit.js
+++ b/common/lib/xmodule/xmodule/public/js/library_content_edit.js
@@ -1,6 +1,14 @@
-/* JavaScript for editing operations that can be done on LibraryContentXBlock */
+/* JavaScript for special editing operations that can be done on LibraryContentXBlock */
window.LibraryContentAuthorView = function (runtime, element) {
- $(element).find('.library-update-btn').on('click', function(e) {
+ "use strict";
+ var usage_id = $(element).data('usage-id');
+ // The "Update Now" button is not a child of 'element', as it is in the validation message area
+ // But it is still inside this xblock's wrapper element, which we can easily find:
+ var $wrapper = $(element).parents('*[data-locator="'+usage_id+'"]');
+
+ // We can't bind to the button itself because in the bok choy test environment,
+ // it may not yet exist at this point in time... not sure why.
+ $wrapper.on('click', '.library-update-btn', function(e) {
e.preventDefault();
// Update the XBlock with the latest matching content from the library:
runtime.notify('save', {
diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py
index 20cf140ed117..dc93be67f561 100644
--- a/common/test/acceptance/pages/studio/container.py
+++ b/common/test/acceptance/pages/studio/container.py
@@ -333,6 +333,45 @@ def children(self):
grand_locators = [grandkid.locator for grandkid in grandkids]
return [descendant for descendant in descendants if descendant.locator not in grand_locators]
+ @property
+ def has_validation_message(self):
+ """ Is a validation warning/error/message shown? """
+ return self.q(css=self._bounded_selector('.xblock-message.validation')).present
+
+ def _validation_paragraph(self, css_class):
+ """ Helper method to return the
element of a validation warning """
+ return self.q(css=self._bounded_selector('.xblock-message.validation p.{}'.format(css_class)))
+
+ @property
+ def has_validation_warning(self):
+ """ Is a validation warning shown? """
+ return self._validation_paragraph('warning').present
+
+ @property
+ def has_validation_error(self):
+ """ Is a validation error shown? """
+ return self._validation_paragraph('error').present
+
+ @property
+ def has_validation_not_configured_warning(self):
+ """ Is a validation "not configured" message shown? """
+ return self._validation_paragraph('not-configured').present
+
+ @property
+ def validation_warning_text(self):
+ """ Get the text of the validation warning. """
+ return self._validation_paragraph('warning').text[0]
+
+ @property
+ def validation_error_text(self):
+ """ Get the text of the validation error. """
+ return self._validation_paragraph('error').text[0]
+
+ @property
+ def validation_not_configured_warning_text(self):
+ """ Get the text of the validation "not configured" message. """
+ return self._validation_paragraph('not-configured').text[0]
+
@property
def preview_selector(self):
return self._bounded_selector('.xblock-student_view,.xblock-author_view')
diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py
index 3151324cd079..ea7f2299f961 100644
--- a/common/test/acceptance/pages/studio/library.py
+++ b/common/test/acceptance/pages/studio/library.py
@@ -246,13 +246,6 @@ def from_xblock_wrapper(cls, xblock_wrapper):
"""
return cls(xblock_wrapper.browser, xblock_wrapper.locator)
- @property
- def header_text(self):
- """
- Gets library content text
- """
- return self.get_body_paragraphs().first.text[0]
-
def get_body_paragraphs(self):
"""
Gets library content body paragraphs
@@ -263,5 +256,7 @@ def refresh_children(self):
"""
Click "Update now..." button
"""
- refresh_button = self.q(css=self._bounded_selector(".library-update-btn"))
+ btn_selector = self._bounded_selector(".library-update-btn")
+ refresh_button = self.q(css=btn_selector)
refresh_button.click()
+ self.wait_for_element_absence(btn_selector, 'Wait for the XBlock to reload')
diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py
index 7bb712c779b5..ba66bdd7b1ec 100644
--- a/common/test/acceptance/tests/studio/test_studio_library_container.py
+++ b/common/test/acceptance/tests/studio/test_studio_library_container.py
@@ -94,40 +94,61 @@ def test_no_library_shows_library_not_configured(self):
And I edit set library key to none
Then I can see that library content block is misconfigured
"""
- expected_text = 'No library or filters configured. Press "Edit" to configure.'
+ expected_text = 'A library has not yet been selected.'
+ expected_action = 'Select a Library'
library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
- # precondition check - assert library is configured before we remove it
- self.assertNotIn(expected_text, library_container.header_text)
+ # precondition check - the library block should be configured before we remove the library setting
+ self.assertFalse(library_container.has_validation_not_configured_warning)
edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
edit_modal.library_key = None
-
library_container.save_settings()
- self.assertIn(expected_text, library_container.header_text)
+ self.assertTrue(library_container.has_validation_not_configured_warning)
+ self.assertIn(expected_text, library_container.validation_not_configured_warning_text)
+ self.assertIn(expected_action, library_container.validation_not_configured_warning_text)
- @ddt.data(
- 'library-v1:111+111',
- 'library-v1:edX+L104',
- )
- def test_set_missing_library_shows_correct_label(self, library_key):
+ def test_set_missing_library_shows_correct_label(self):
"""
Scenario: Given I have a library, a course and library content xblock in a course
When I go to studio unit page for library content block
And I edit set library key to non-existent library
Then I can see that library content block is misconfigured
"""
+ nonexistent_lib_key = 'library-v1:111+111'
expected_text = "Library is invalid, corrupt, or has been deleted."
library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
# precondition check - assert library is configured before we remove it
- self.assertNotIn(expected_text, library_container.header_text)
+ self.assertFalse(library_container.has_validation_error)
edit_modal = StudioLibraryContentXBlockEditModal(library_container.edit())
- edit_modal.library_key = library_key
+ edit_modal.library_key = nonexistent_lib_key
library_container.save_settings()
- self.assertIn(expected_text, library_container.header_text)
+ self.assertTrue(library_container.has_validation_error)
+ self.assertIn(expected_text, library_container.validation_error_text)
+
+ def test_out_of_date_message(self):
+ """
+ Scenario: Given I have a library, a course and library content xblock in a course
+ When I go to studio unit page for library content block
+ Then I can see that library content block needs to be updated
+ When I click on the update link
+ Then I can see that the content no longer needs to be updated
+ """
+ expected_text = "This component is out of date. The library has new content."
+ library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+
+ self.assertTrue(library_container.has_validation_warning)
+ self.assertIn(expected_text, library_container.validation_warning_text)
+
+ library_container.refresh_children()
+
+ self.unit_page.wait_for_page() # Wait for the page to reload
+ library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+
+ self.assertFalse(library_container.has_validation_message)
diff --git a/lms/templates/library-block-author-preview-header.html b/lms/templates/library-block-author-preview-header.html
index 4596281b6702..ad76623deb6a 100644
--- a/lms/templates/library-block-author-preview-header.html
+++ b/lms/templates/library-block-author-preview-header.html
@@ -3,7 +3,7 @@
- ${_('Showing all matching content eligible to be added into {display_name}. Each student will be assigned {mode} {max_count} components from this list.').format(max_count=max_count, display_name=display_name, mode=mode)}
+ ${_('Showing all matching content eligible to be added into {display_name}. Each student will be assigned {max_count} component[s] drawn randomly from this list.').format(max_count=max_count, display_name=display_name)}
diff --git a/lms/templates/library-block-author-view.html b/lms/templates/library-block-author-view.html
index ce1542cc5888..46202aa2a9c6 100644
--- a/lms/templates/library-block-author-view.html
+++ b/lms/templates/library-block-author-view.html
@@ -2,12 +2,5 @@
from django.utils.translation import ugettext as _
%>
- % if library_ok:
-
${_('This component will be replaced by {mode} {max_count} components from the {num_children} matching components from {lib_names}.').format(mode=mode, max_count=max_count, num_children=num_children, lib_names=', '.join(library_names))}
${_('This component will be replaced by {max_count} component[s] randomly chosen from the {num_children} matching components in {lib_names}.').format(mode=mode, max_count=max_count, num_children=num_children, lib_names=', '.join(library_names))}
From e20b904b6b64ef65ac1393bc92709832ec71f644 Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Wed, 10 Dec 2014 20:59:49 -0800
Subject: [PATCH 15/23] Fix: don't need to reload the whole page to
refresh_children from the container view
---
.../xmodule/public/js/library_content_edit.js | 20 +++++++++++--------
1 file changed, 12 insertions(+), 8 deletions(-)
diff --git a/common/lib/xmodule/xmodule/public/js/library_content_edit.js b/common/lib/xmodule/xmodule/public/js/library_content_edit.js
index 2db019feddf2..89011789b99b 100644
--- a/common/lib/xmodule/xmodule/public/js/library_content_edit.js
+++ b/common/lib/xmodule/xmodule/public/js/library_content_edit.js
@@ -1,10 +1,11 @@
/* JavaScript for special editing operations that can be done on LibraryContentXBlock */
window.LibraryContentAuthorView = function (runtime, element) {
"use strict";
- var usage_id = $(element).data('usage-id');
+ var $element = $(element);
+ var usage_id = $element.data('usage-id');
// The "Update Now" button is not a child of 'element', as it is in the validation message area
// But it is still inside this xblock's wrapper element, which we can easily find:
- var $wrapper = $(element).parents('*[data-locator="'+usage_id+'"]');
+ var $wrapper = $element.parents('*[data-locator="'+usage_id+'"]');
// We can't bind to the button itself because in the bok choy test environment,
// it may not yet exist at this point in time... not sure why.
@@ -21,12 +22,15 @@ window.LibraryContentAuthorView = function (runtime, element) {
state: 'end',
element: element
});
- // runtime.refreshXBlock(element);
- // The above does not work, because this XBlock's runtime has no reference
- // to the page (XBlockContainerPage). Only the Vertical XBlock's runtime has
- // a reference to the page, and we have no way of getting a reference to it.
- // So instead we:
- location.reload();
+ if ($element.closest('.wrapper-xblock').is(':not(.level-page)')) {
+ // We are on a course unit page. The notify('save') should refresh this block,
+ // but that is only working on the container page view of this block.
+ // Why? On the unit page, this XBlock's runtime has no reference to the
+ // XBlockContainerPage - only the top-level XBlock (a vertical) runtime does.
+ // But unfortunately there is no way to get a reference to our parent block's
+ // JS 'runtime' object. So instead we must refresh the whole page:
+ location.reload();
+ }
});
});
};
From 66f5f8f25799817d60d9bc3a9a207ec304ce233d Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Thu, 11 Dec 2014 00:40:08 -0800
Subject: [PATCH 16/23] Refresh children automatically when library setting is
changed
---
.../xmodule/xmodule/library_content_module.py | 24 ++++++++++++++++---
.../test/acceptance/pages/studio/container.py | 8 +++++++
.../test/acceptance/tests/lms/test_library.py | 1 -
.../studio/test_studio_library_container.py | 22 ++++++++++++-----
4 files changed, 45 insertions(+), 10 deletions(-)
diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py
index d9e28e93fd09..f89f147330dd 100644
--- a/common/lib/xmodule/xmodule/library_content_module.py
+++ b/common/lib/xmodule/xmodule/library_content_module.py
@@ -363,7 +363,7 @@ class LibraryContentDescriptor(LibraryContentFields, MakoModuleDescriptor, XmlDe
js_module_name = "VerticalDescriptor"
@XBlock.handler
- def refresh_children(self, request, suffix): # pylint: disable=unused-argument
+ def refresh_children(self, request, suffix, update_db=True): # pylint: disable=unused-argument
"""
Refresh children:
This method is to be used when any of the libraries that this block
@@ -375,8 +375,12 @@ def refresh_children(self, request, suffix): # pylint: disable=unused-argument
This method will update this block's 'source_libraries' field to store
the version number of the libraries used, so we easily determine if
this block is up to date or not.
+
+ If update_db is True (default), this will explicitly persist the changes
+ to the modulestore by calling update_item()
"""
- user_id = self.runtime.service(self, 'user').user_id
+ user_service = self.runtime.service(self, 'user')
+ user_id = user_service.user_id if user_service else None # May be None when creating bok choy test fixtures
root_children = []
store = self.system.modulestore
@@ -395,6 +399,8 @@ def refresh_children(self, request, suffix): # pylint: disable=unused-argument
new_libraries = []
for library_key, old_version in self.source_libraries: # pylint: disable=unused-variable
library = _get_library(self.system.modulestore, library_key) # pylint: disable=protected-access
+ if library is None:
+ raise ValueError("Required library not found.")
def copy_children_recursively(from_block):
"""
@@ -434,9 +440,21 @@ def copy_children_recursively(from_block):
new_libraries.append(LibraryVersionReference(library_key, library.location.library_key.version_guid))
self.source_libraries = new_libraries
self.children = root_children # pylint: disable=attribute-defined-outside-init
- self.system.modulestore.update_item(self, user_id)
+ if update_db:
+ self.system.modulestore.update_item(self, user_id)
return Response()
+ def editor_saved(self, user, old_metadata, old_content):
+ """
+ If source_libraries has been edited, refresh_children automatically.
+ """
+ old_source_libraries = LibraryList().from_json(old_metadata.get('source_libraries', []))
+ if set(old_source_libraries) != set(self.source_libraries):
+ try:
+ self.refresh_children(None, None, update_db=False) # update_db=False since update_item() is about to be called anyways
+ except ValueError:
+ pass # The validation area will display an error message, no need to do anything now.
+
def has_dynamic_children(self):
"""
Inform the runtime that our children vary per-user.
diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py
index dc93be67f561..d8a760cac972 100644
--- a/common/test/acceptance/pages/studio/container.py
+++ b/common/test/acceptance/pages/studio/container.py
@@ -309,6 +309,14 @@ def student_content(self):
"""
return self.q(css=self._bounded_selector('.xblock-student_view'))[0].text
+ @property
+ def author_content(self):
+ """
+ Returns the text content of the xblock as displayed on the container page.
+ (For blocks which implement a distinct author_view).
+ """
+ return self.q(css=self._bounded_selector('.xblock-author_view'))[0].text
+
@property
def name(self):
titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text
diff --git a/common/test/acceptance/tests/lms/test_library.py b/common/test/acceptance/tests/lms/test_library.py
index 78d699faa6fd..f83e6b94e9d5 100644
--- a/common/test/acceptance/tests/lms/test_library.py
+++ b/common/test/acceptance/tests/lms/test_library.py
@@ -92,7 +92,6 @@ def _refresh_library_content_children(self, count=1):
modal = StudioLibraryContentXBlockEditModal(library_container_block.edit())
modal.count = count
library_container_block.save_settings()
- library_container_block.refresh_children()
self._go_to_unit_page(change_login=False)
unit_page.wait_for_page()
unit_page.publish_action.click()
diff --git a/common/test/acceptance/tests/studio/test_studio_library_container.py b/common/test/acceptance/tests/studio/test_studio_library_container.py
index ba66bdd7b1ec..6e8fddeb8efb 100644
--- a/common/test/acceptance/tests/studio/test_studio_library_container.py
+++ b/common/test/acceptance/tests/studio/test_studio_library_container.py
@@ -136,19 +136,29 @@ def test_out_of_date_message(self):
"""
Scenario: Given I have a library, a course and library content xblock in a course
When I go to studio unit page for library content block
+ Then I update the library being used
+ Then I refresh the page
Then I can see that library content block needs to be updated
When I click on the update link
Then I can see that the content no longer needs to be updated
"""
expected_text = "This component is out of date. The library has new content."
- library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+ library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+
+ self.assertFalse(library_block.has_validation_warning)
+ self.assertIn("3 matching components", library_block.author_content)
+
+ self.library_fixture.create_xblock(self.library_fixture.library_location, XBlockFixtureDesc("html", "Html4"))
- self.assertTrue(library_container.has_validation_warning)
- self.assertIn(expected_text, library_container.validation_warning_text)
+ self.unit_page.visit() # Reload the page
- library_container.refresh_children()
+ self.assertTrue(library_block.has_validation_warning)
+ self.assertIn(expected_text, library_block.validation_warning_text)
+
+ library_block.refresh_children()
self.unit_page.wait_for_page() # Wait for the page to reload
- library_container = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
+ library_block = self._get_library_xblock_wrapper(self.unit_page.xblocks[0])
- self.assertFalse(library_container.has_validation_message)
+ self.assertFalse(library_block.has_validation_message)
+ self.assertIn("4 matching components", library_block.author_content)
From c2f757ffe0dfe52f2f42ee39a403e538e0fa344b Mon Sep 17 00:00:00 2001
From: Braden MacDonald
Date: Thu, 11 Dec 2014 15:10:38 -0800
Subject: [PATCH 17/23] Fix greedy intrusion of split_test documentation
---
cms/templates/container.html | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/cms/templates/container.html b/cms/templates/container.html
index de395457f24b..e36914506b1b 100644
--- a/cms/templates/container.html
+++ b/cms/templates/container.html
@@ -103,7 +103,7 @@