diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index b12fc47216..f9d007c62e 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
+Studio/LMS: Implement cohorted courseware. TNL-648
+
LMS: Student Notes: Eventing for Student Notes. TNL-931
LMS: Student Notes: Add course structure view. TNL-762
diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py
index 8698feb6d9..76b809a96d 100644
--- a/cms/djangoapps/contentstore/tests/test_utils.py
+++ b/cms/djangoapps/contentstore/tests/test_utils.py
@@ -423,3 +423,63 @@ class InheritedStaffLockTest(StaffLockTest):
def test_no_inheritance_for_orphan(self):
"""Tests that an orphaned xblock does not inherit staff lock"""
self.assertFalse(utils.ancestor_has_staff_lock(self.orphan))
+
+
+class GroupVisibilityTest(CourseTestCase):
+ """
+ Test content group access rules.
+ """
+ def setUp(self):
+ super(GroupVisibilityTest, self).setUp()
+
+ chapter = ItemFactory.create(category='chapter', parent_location=self.course.location)
+ sequential = ItemFactory.create(category='sequential', parent_location=chapter.location)
+ vertical = ItemFactory.create(category='vertical', parent_location=sequential.location)
+ html = ItemFactory.create(category='html', parent_location=vertical.location)
+ problem = ItemFactory.create(
+ category='problem', parent_location=vertical.location, data=""
+ )
+ self.sequential = self.store.get_item(sequential.location)
+ self.vertical = self.store.get_item(vertical.location)
+ self.html = self.store.get_item(html.location)
+ self.problem = self.store.get_item(problem.location)
+
+ def set_group_access(self, xblock, value):
+ """ Sets group_access to specified value and calls update_item to persist the change. """
+ xblock.group_access = value
+ self.store.update_item(xblock, self.user.id)
+
+ def test_no_visibility_set(self):
+ """ Tests when group_access has not been set on anything. """
+
+ def verify_all_components_visible_to_all(): # pylint: disable=invalid-name
+ """ Verifies when group_access has not been set on anything. """
+ for item in (self.sequential, self.vertical, self.html, self.problem):
+ self.assertFalse(utils.has_children_visible_to_specific_content_groups(item))
+ self.assertFalse(utils.is_visible_to_specific_content_groups(item))
+
+ verify_all_components_visible_to_all()
+
+ # Test with group_access set to Falsey values.
+ self.set_group_access(self.vertical, {1: []})
+ self.set_group_access(self.html, {2: None})
+
+ verify_all_components_visible_to_all()
+
+ def test_sequential_and_problem_have_group_access(self):
+ """ Tests when group_access is set on a few different components. """
+ self.set_group_access(self.sequential, {1: [0]})
+ # This is a no-op.
+ self.set_group_access(self.vertical, {1: []})
+ self.set_group_access(self.problem, {2: [3, 4]})
+
+ # Note that "has_children_visible_to_specific_content_groups" only checks immediate children.
+ self.assertFalse(utils.has_children_visible_to_specific_content_groups(self.sequential))
+ self.assertTrue(utils.has_children_visible_to_specific_content_groups(self.vertical))
+ self.assertFalse(utils.has_children_visible_to_specific_content_groups(self.html))
+ self.assertFalse(utils.has_children_visible_to_specific_content_groups(self.problem))
+
+ self.assertTrue(utils.is_visible_to_specific_content_groups(self.sequential))
+ self.assertFalse(utils.is_visible_to_specific_content_groups(self.vertical))
+ self.assertFalse(utils.is_visible_to_specific_content_groups(self.html))
+ self.assertTrue(utils.is_visible_to_specific_content_groups(self.problem))
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 07a8a8cac1..41fca796f4 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -179,6 +179,36 @@ def is_currently_visible_to_students(xblock):
return True
+def has_children_visible_to_specific_content_groups(xblock):
+ """
+ Returns True if this xblock has children that are limited to specific content groups.
+ Note that this method is not recursive (it does not check grandchildren).
+ """
+ if not xblock.has_children:
+ return False
+
+ for child in xblock.get_children():
+ if is_visible_to_specific_content_groups(child):
+ return True
+
+ return False
+
+
+def is_visible_to_specific_content_groups(xblock):
+ """
+ Returns True if this xblock has visibility limited to specific content groups.
+ """
+ if not xblock.group_access:
+ return False
+ for __, value in xblock.group_access.iteritems():
+ # value should be a list of group IDs. If it is an empty list or None, the xblock is visible
+ # to all groups in that particular partition. So if value is a truthy value, the xblock is
+ # restricted in some way.
+ if value:
+ return True
+ return False
+
+
def find_release_date_source(xblock):
"""
Finds the ancestor of xblock that set its release date.
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 90f1dde267..ea4412e7b7 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -21,7 +21,7 @@ from xblock.runtime import Mixologist
from contentstore.utils import get_lms_link_for_item
from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name
-from contentstore.views.item import create_xblock_info
+from contentstore.views.item import create_xblock_info, add_container_page_publishing_info
from opaque_keys.edx.keys import UsageKey
@@ -186,8 +186,9 @@ def container_handler(request, usage_key_string):
# about the block's ancestors and siblings for use by the Unit Outline.
xblock_info = create_xblock_info(xblock, include_ancestor_info=is_unit_page)
- # Create the link for preview.
- preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE')
+ if is_unit_page:
+ add_container_page_publishing_info(xblock, xblock_info)
+
# need to figure out where this item is in the list of children as the
# preview will need this
index = 1
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index f8b4de4781..b25cf6895f 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -16,6 +16,7 @@ from django.core.urlresolvers import reverse
from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse, Http404
from util.json_request import JsonResponse, JsonResponseBadRequest
from util.date_utils import get_default_time_display
+from util.db import generate_int_id, MYSQL_MAX_INT
from edxmako.shortcuts import render_to_response
from xmodule.course_module import DEFAULT_START_DATE
@@ -29,6 +30,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseErr
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import Location
from opaque_keys.edx.keys import CourseKey
+from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
from django_future.csrf import ensure_csrf_cookie
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
@@ -70,7 +72,15 @@ from course_action_state.models import CourseRerunState, CourseRerunUIStateManag
from course_action_state.managers import CourseActionStateItemNotFoundError
from microsite_configuration import microsite
from xmodule.course_module import CourseFields
+from xmodule.split_test_module import get_split_user_partitions
+MINIMUM_GROUP_ID = 100
+
+# Note: the following content group configuration strings are not
+# translated since they are not visible to users.
+CONTENT_GROUP_CONFIGURATION_DESCRIPTION = 'The groups in this configuration can be mapped to cohort groups in the LMS.'
+
+CONTENT_GROUP_CONFIGURATION_NAME = 'Content Group Configuration'
__all__ = ['course_info_handler', 'course_handler', 'course_listing',
'course_info_update_handler',
@@ -1252,23 +1262,16 @@ class GroupConfiguration(object):
if len(self.configuration.get('groups', [])) < 1:
raise GroupConfigurationsValidationError(_("must have at least one group"))
- def generate_id(self, used_ids):
- """
- Generate unique id for the group configuration.
- If this id is already used, we generate new one.
- """
- cid = random.randint(100, 10 ** 12)
-
- while cid in used_ids:
- cid = random.randint(100, 10 ** 12)
-
- return cid
-
def assign_id(self, configuration_id=None):
"""
Assign id for the json representation of group configuration.
"""
- self.configuration['id'] = int(configuration_id) if configuration_id else self.generate_id(self.get_used_ids())
+ if configuration_id:
+ self.configuration['id'] = int(configuration_id)
+ else:
+ self.configuration['id'] = generate_int_id(
+ MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(self.course)
+ )
def assign_group_ids(self):
"""
@@ -1278,14 +1281,15 @@ class GroupConfiguration(object):
# Assign ids to every group in configuration.
for group in self.configuration.get('groups', []):
if group.get('id') is None:
- group["id"] = self.generate_id(used_ids)
+ group["id"] = generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, used_ids)
used_ids.append(group["id"])
- def get_used_ids(self):
+ @staticmethod
+ def get_used_ids(course):
"""
Return a list of IDs that already in use.
"""
- return set([p.id for p in self.course.user_partitions])
+ return set([p.id for p in course.user_partitions])
def get_user_partition(self):
"""
@@ -1296,21 +1300,19 @@ class GroupConfiguration(object):
@staticmethod
def get_usage_info(course, store):
"""
- Get usage information for all Group Configurations.
+ Get usage information for all Group Configurations currently referenced by a split_test instance.
"""
split_tests = store.get_items(course.id, qualifiers={'category': 'split_test'})
return GroupConfiguration._get_usage_info(store, course, split_tests)
@staticmethod
- def add_usage_info(course, store):
+ def get_split_test_partitions_with_usage(course, store):
"""
- Add usage information to group configurations jsons in course.
-
- Returns json of group configurations updated with usage information.
+ Returns json split_test group configurations updated with usage information.
"""
usage_info = GroupConfiguration.get_usage_info(course, store)
configurations = []
- for partition in course.user_partitions:
+ for partition in get_split_user_partitions(course.user_partitions):
configuration = partition.to_json()
configuration['usage'] = usage_info.get(partition.id, [])
configurations.append(configuration)
@@ -1384,6 +1386,26 @@ class GroupConfiguration(object):
configuration_json['usage'] = usage_information.get(configuration.id, [])
return configuration_json
+ @staticmethod
+ def get_or_create_content_group_configuration(course):
+ """
+ Returns the first user partition from the course which uses the
+ CohortPartitionScheme, or generates one if no such partition is
+ found. The created partition is not saved to the course until
+ the client explicitly creates a group within the partition and
+ POSTs back.
+ """
+ content_group_configuration = get_cohorted_user_partition(course.id)
+ if content_group_configuration is None:
+ content_group_configuration = UserPartition(
+ id=generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(course)),
+ name=CONTENT_GROUP_CONFIGURATION_NAME,
+ description=CONTENT_GROUP_CONFIGURATION_DESCRIPTION,
+ groups=[],
+ scheme_id='cohort'
+ )
+ return content_group_configuration
+
@require_http_methods(("GET", "POST"))
@login_required
@@ -1405,12 +1427,21 @@ def group_configurations_list_handler(request, course_key_string):
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key)
course_outline_url = reverse_course_url('course_handler', course_key)
- configurations = GroupConfiguration.add_usage_info(course, store)
+ should_show_experiment_groups = are_content_experiments_enabled(course)
+ if should_show_experiment_groups:
+ experiment_group_configurations = GroupConfiguration.get_split_test_partitions_with_usage(course, store)
+ else:
+ experiment_group_configurations = None
+ content_group_configuration = GroupConfiguration.get_or_create_content_group_configuration(
+ course
+ ).to_json()
return render_to_response('group_configurations.html', {
'context_course': course,
'group_configuration_url': group_configuration_url,
'course_outline_url': course_outline_url,
- 'configurations': configurations if should_show_group_configurations_page(course) else None,
+ 'experiment_group_configurations': experiment_group_configurations,
+ 'should_show_experiment_groups': should_show_experiment_groups,
+ 'content_group_configuration': content_group_configuration
})
elif "application/json" in request.META.get('HTTP_ACCEPT'):
if request.method == 'POST':
@@ -1489,9 +1520,9 @@ def group_configurations_detail_handler(request, course_key_string, group_config
return JsonResponse(status=204)
-def should_show_group_configurations_page(course):
+def are_content_experiments_enabled(course):
"""
- Returns true if Studio should show the "Group Configurations" page for the specified course.
+ Returns True if content experiments have been enabled for the course.
"""
return (
SPLIT_TEST_COMPONENT_TYPE in ADVANCED_COMPONENT_TYPES and
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index e390811096..d93ac2c42e 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -39,7 +39,7 @@ from util.json_request import expect_json, JsonResponse
from student.auth import has_studio_write_access, has_studio_read_access
from contentstore.utils import find_release_date_source, find_staff_lock_source, is_currently_visible_to_students, \
- ancestor_has_staff_lock
+ ancestor_has_staff_lock, has_children_visible_to_specific_content_groups
from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \
xblock_type_display_name, get_parent_xblock
from contentstore.views.preview import get_preview_fragment
@@ -48,8 +48,11 @@ from models.settings.course_grading import CourseGradingModel
from cms.lib.xblock.runtime import handler_url, local_resource_url
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.locator import LibraryUsageLocator
+from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
-__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler']
+__all__ = [
+ 'orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler', 'xblock_container_handler'
+]
log = logging.getLogger(__name__)
@@ -59,7 +62,6 @@ CREATE_IF_NOT_FOUND = ['course_info']
NEVER = lambda x: False
ALWAYS = lambda x: True
-
# In order to allow descriptors to use a handler url, we need to
# monkey-patch the x_module library.
# TODO: Remove this code when Runtimes are no longer created by modulestores
@@ -143,8 +145,9 @@ def xblock_handler(request, usage_key_string):
# right now can't combine output of this w/ output of _get_module_info, but worthy goal
return JsonResponse(CourseGradingModel.get_section_grader_type(usage_key))
# TODO: pass fields to _get_module_info and only return those
- rsp = _get_module_info(_get_xblock(usage_key, request.user))
- return JsonResponse(rsp)
+ with modulestore().bulk_operations(usage_key.course_key):
+ response = _get_module_info(_get_xblock(usage_key, request.user))
+ return JsonResponse(response)
else:
return HttpResponse(status=406)
@@ -225,14 +228,14 @@ def xblock_view_handler(request, usage_key_string, view_name):
request_token=request_token(request),
))
- if view_name == STUDIO_VIEW:
+ if view_name in (STUDIO_VIEW, VISIBILITY_VIEW):
try:
- fragment = xblock.render(STUDIO_VIEW)
+ fragment = xblock.render(view_name)
# catch exceptions indiscriminately, since after this point they escape the
# dungeon and surface as uneditable, unsaveable, and undeletable
# component-goblins.
except Exception as exc: # pylint: disable=broad-except
- log.debug("unable to render studio_view for %r", xblock, exc_info=True)
+ log.debug("Unable to render %s for %r", view_name, xblock, exc_info=True)
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
elif view_name in (PREVIEW_VIEWS + container_views):
@@ -333,6 +336,31 @@ def xblock_outline_handler(request, usage_key_string):
return Http404
+@require_http_methods(("GET"))
+@login_required
+@expect_json
+def xblock_container_handler(request, usage_key_string):
+ """
+ The restful handler for requests for XBlock information about the block and its children.
+ This is used by the container page in particular to get additional information about publish state
+ and ancestor state.
+ """
+ usage_key = usage_key_with_run(usage_key_string)
+
+ if not has_studio_read_access(request.user, usage_key.course_key):
+ raise PermissionDenied()
+
+ response_format = request.REQUEST.get('format', 'html')
+ if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
+ with modulestore().bulk_operations(usage_key.course_key):
+ response = _get_module_info(
+ _get_xblock(usage_key, request.user), include_ancestor_info=True, include_publishing_info=True
+ )
+ return JsonResponse(response)
+ else:
+ return Http404
+
+
def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
"""
Updates the xblock in the modulestore.
@@ -695,7 +723,7 @@ def _get_xblock(usage_key, user):
return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
-def _get_module_info(xblock, rewrite_static_links=True):
+def _get_module_info(xblock, rewrite_static_links=True, include_ancestor_info=False, include_publishing_info=False):
"""
metadata, data, id representation of a leaf module fetcher.
:param usage_key: A UsageKey
@@ -715,7 +743,12 @@ def _get_module_info(xblock, rewrite_static_links=True):
modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None))
# Note that children aren't being returned until we have a use case.
- return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True)
+ xblock_info = create_xblock_info(
+ xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=include_ancestor_info
+ )
+ if include_publishing_info:
+ add_container_page_publishing_info(xblock, xblock_info)
+ return xblock_info
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False,
@@ -735,24 +768,6 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
In addition, an optional include_children_predicate argument can be provided to define whether or
not a particular xblock should have its children included.
"""
-
- def safe_get_username(user_id):
- """
- Guard against bad user_ids, like the infamous "**replace_user**".
- Note that this will ignore our special known IDs (ModuleStoreEnum.UserID).
- We should consider adding special handling for those values.
-
- :param user_id: the user id to get the username of
- :return: username, or None if the user does not exist or user_id is None
- """
- if user_id:
- try:
- return User.objects.get(id=user_id).username
- except: # pylint: disable=bare-except
- pass
-
- return None
-
is_library_block = isinstance(xblock.location, LibraryUsageLocator)
is_xblock_unit = is_unit(xblock, parent_xblock)
# this should not be calculated for Sections and Subsections on Unit page or for library blocks
@@ -778,8 +793,6 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
else:
child_info = None
- # Treat DEFAULT_START_DATE as a magic number that means the release date has not been set
- release_date = get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None
if xblock.category != 'course':
visibility_state = _compute_visibility_state(xblock, child_info, is_xblock_unit and has_changes)
else:
@@ -795,7 +808,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
"published_on": get_default_time_display(xblock.published_on) if published and xblock.published_on else None,
"studio_url": xblock_studio_url(xblock, parent_xblock),
"released_to_students": datetime.now(UTC) > xblock.start,
- "release_date": release_date,
+ "release_date": _get_release_date(xblock),
"visibility_state": visibility_state,
"has_explicit_staff_lock": xblock.fields['visible_to_staff_only'].is_set_on(xblock),
"start": xblock.fields['start'].to_json(xblock.start),
@@ -819,19 +832,6 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
else:
xblock_info["ancestor_has_staff_lock"] = False
- # Currently, 'edited_by', 'published_by', and 'release_date_from' are only used by the
- # container page when rendering a unit. Since they are expensive to compute, only include them for units
- # that are not being rendered on the course outline.
- if is_xblock_unit and not course_outline:
- xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by)
- xblock_info["published_by"] = safe_get_username(xblock.published_by)
- xblock_info["currently_visible_to_students"] = is_currently_visible_to_students(xblock)
- if release_date:
- xblock_info["release_date_from"] = _get_release_date_from(xblock)
- if visibility_state == VisibilityState.staff_only:
- xblock_info["staff_lock_from"] = _get_staff_lock_from(xblock)
- else:
- xblock_info["staff_lock_from"] = None
if course_outline:
if xblock_info["has_explicit_staff_lock"]:
xblock_info["staff_only_message"] = True
@@ -843,6 +843,40 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
return xblock_info
+def add_container_page_publishing_info(xblock, xblock_info): # pylint: disable=invalid-name
+ """
+ Adds information about the xblock's publish state to the supplied
+ xblock_info for the container page.
+ """
+ def safe_get_username(user_id):
+ """
+ Guard against bad user_ids, like the infamous "**replace_user**".
+ Note that this will ignore our special known IDs (ModuleStoreEnum.UserID).
+ We should consider adding special handling for those values.
+
+ :param user_id: the user id to get the username of
+ :return: username, or None if the user does not exist or user_id is None
+ """
+ if user_id:
+ try:
+ return User.objects.get(id=user_id).username
+ except: # pylint: disable=bare-except
+ pass
+
+ return None
+
+ xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by)
+ xblock_info["published_by"] = safe_get_username(xblock.published_by)
+ xblock_info["currently_visible_to_students"] = is_currently_visible_to_students(xblock)
+ xblock_info["has_content_group_components"] = has_children_visible_to_specific_content_groups(xblock)
+ if xblock_info["release_date"]:
+ xblock_info["release_date_from"] = _get_release_date_from(xblock)
+ if xblock_info["visibility_state"] == VisibilityState.staff_only:
+ xblock_info["staff_lock_from"] = _get_staff_lock_from(xblock)
+ else:
+ xblock_info["staff_lock_from"] = None
+
+
class VisibilityState(object):
"""
Represents the possible visibility states for an xblock:
@@ -962,6 +996,14 @@ def _create_xblock_child_info(xblock, course_outline, graders, include_children_
return child_info
+def _get_release_date(xblock):
+ """
+ Returns the release date for the xblock, or None if the release date has never been set.
+ """
+ # Treat DEFAULT_START_DATE as a magic number that means the release date has not been set
+ return get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None
+
+
def _get_release_date_from(xblock):
"""
Returns a string representation of the section or subsection that sets the xblock's release date
diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py
index 63e1a6b792..528b2714c9 100644
--- a/cms/djangoapps/contentstore/views/preview.py
+++ b/cms/djangoapps/contentstore/views/preview.py
@@ -17,6 +17,7 @@ from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.library_tools import LibraryToolsService
from xmodule.modulestore.django import modulestore, ModuleI18nService
from opaque_keys.edx.keys import UsageKey
+from opaque_keys.edx.locator import LibraryUsageLocator
from xmodule.x_module import ModuleSystem
from xblock.runtime import KvsFieldData
from xblock.django.request import webob_to_django_response, django_to_webob_request
@@ -242,6 +243,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
# Only add the Studio wrapper when on the container page. The "Pages" page will remain as is for now.
if not context.get('is_pages_view', None) and view in PREVIEW_VIEWS:
root_xblock = context.get('root_xblock')
+ can_edit_visibility = not isinstance(xblock.location, LibraryUsageLocator)
is_root = root_xblock and xblock.location == root_xblock.location
is_reorderable = _is_xblock_reorderable(xblock, context)
template_context = {
@@ -251,6 +253,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
'is_root': is_root,
'is_reorderable': is_reorderable,
'can_edit': context.get('can_edit', True),
+ 'can_edit_visibility': can_edit_visibility,
}
html = render_to_string('studio_xblock_wrapper.html', template_context)
frag = wrap_fragment(frag, html)
diff --git a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py
index 5c0e5130d1..9845d78d47 100644
--- a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py
+++ b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py
@@ -207,17 +207,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'First name')
self.assertContains(response, 'Group C')
-
- def test_view_index_disabled(self):
- """
- Check that group configuration page is not displayed when turned off.
- """
- if SPLIT_TEST_COMPONENT_TYPE in self.course.advanced_modules:
- self.course.advanced_modules.remove(SPLIT_TEST_COMPONENT_TYPE)
- self.store.update_item(self.course, self.user.id)
-
- resp = self.client.get(self._url())
- self.assertContains(resp, "module is disabled")
+ self.assertContains(response, 'Content Group Configuration')
def test_unsupported_http_accept_header(self):
"""
@@ -243,12 +233,9 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
{u'name': u'Group B', u'version': 1},
],
}
- response = self.client.post(
+ response = self.client.ajax_post(
self._url(),
- data=json.dumps(GROUP_CONFIGURATION_JSON),
- content_type="application/json",
- HTTP_ACCEPT="application/json",
- HTTP_X_REQUESTED_WITH="XMLHttpRequest",
+ data=GROUP_CONFIGURATION_JSON
)
self.assertEqual(response.status_code, 201)
self.assertIn("Location", response)
@@ -267,6 +254,16 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
self.assertEqual(user_partititons[0].groups[0].name, u'Group A')
self.assertEqual(user_partititons[0].groups[1].name, u'Group B')
+ def test_lazily_creates_cohort_configuration(self):
+ """
+ Test that a cohort schemed user partition is NOT created by
+ default for the user.
+ """
+ self.assertEqual(len(self.course.user_partitions), 0)
+ self.client.get(self._url())
+ self.reload_course()
+ self.assertEqual(len(self.course.user_partitions), 0)
+
# pylint: disable=no-member
class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfigurationsBaseTestCase, HelperMethods):
@@ -436,7 +433,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
Test that right data structure will be created if group configuration is not used.
"""
self._add_user_partitions()
- actual = GroupConfiguration.add_usage_info(self.course, self.store)
+ actual = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)
expected = [{
'id': 0,
'name': 'Name 0',
@@ -460,7 +457,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0')
self._create_content_experiment(name_suffix='1')
- actual = GroupConfiguration.add_usage_info(self.course, self.store)
+ actual = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)
expected = [{
'id': 0,
@@ -503,7 +500,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0')
vertical1, __ = self._create_content_experiment(cid=0, name_suffix='1')
- actual = GroupConfiguration.add_usage_info(self.course, self.store)
+ actual = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)
expected = [{
'id': 0,
@@ -567,7 +564,7 @@ class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods):
validation.add(mocked_message)
mocked_validation_messages.return_value = validation
- group_configuration = GroupConfiguration.add_usage_info(self.course, self.store)[0]
+ group_configuration = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)[0]
self.assertEqual(expected_result.to_json(), group_configuration['usage'][0]['validation'])
def test_error_message_present(self):
diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py
index c322233319..01a84dea5c 100644
--- a/cms/djangoapps/contentstore/views/tests/test_item.py
+++ b/cms/djangoapps/contentstore/views/tests/test_item.py
@@ -18,8 +18,9 @@ from contentstore.views.component import (
component_handler, get_component_templates
)
-
-from contentstore.views.item import create_xblock_info, ALWAYS, VisibilityState, _xblock_type_and_display_name
+from contentstore.views.item import (
+ create_xblock_info, ALWAYS, VisibilityState, _xblock_type_and_display_name, add_container_page_publishing_info
+)
from contentstore.tests.utils import CourseTestCase
from student.tests.factories import UserFactory
from xmodule.capa_module import CapaDescriptor
@@ -116,9 +117,9 @@ class GetItemTest(ItemTest):
return resp
@ddt.data(
- (1, 21, 23, 35, 37),
- (2, 22, 24, 38, 39),
- (3, 23, 25, 41, 41),
+ (1, 16, 14, 15, 11),
+ (2, 16, 14, 15, 11),
+ (3, 16, 14, 15, 11),
)
@ddt.unpack
def test_get_query_count(self, branching_factor, chapter_queries, section_queries, unit_queries, problem_queries):
@@ -133,6 +134,17 @@ class GetItemTest(ItemTest):
with check_mongo_calls(problem_queries):
self.client.get(reverse_usage_url('xblock_handler', self.populated_usage_keys['problem'][-1]))
+ @ddt.data(
+ (1, 26),
+ (2, 28),
+ (3, 30),
+ )
+ @ddt.unpack
+ def test_container_get_query_count(self, branching_factor, unit_queries,):
+ self.populate_course(branching_factor)
+ with check_mongo_calls(unit_queries):
+ self.client.get(reverse_usage_url('xblock_container_handler', self.populated_usage_keys['vertical'][-1]))
+
def test_get_vertical(self):
# Add a vertical
resp = self.create_xblock(category='vertical')
@@ -411,21 +423,46 @@ class TestDuplicateItem(ItemTest):
except for location and display name.
"""
def duplicate_and_verify(source_usage_key, parent_usage_key):
+ """ Duplicates the source, parenting to supplied parent. Then does equality check. """
usage_key = self._duplicate_item(parent_usage_key, source_usage_key)
- self.assertTrue(check_equality(source_usage_key, usage_key), "Duplicated item differs from original")
+ self.assertTrue(
+ check_equality(source_usage_key, usage_key, parent_usage_key),
+ "Duplicated item differs from original"
+ )
- def check_equality(source_usage_key, duplicate_usage_key):
+ def check_equality(source_usage_key, duplicate_usage_key, parent_usage_key=None):
+ """
+ Gets source and duplicated items from the modulestore using supplied usage keys.
+ Then verifies that they represent equivalent items (modulo parents and other
+ known things that may differ).
+ """
original_item = self.get_item_from_modulestore(source_usage_key)
duplicated_item = self.get_item_from_modulestore(duplicate_usage_key)
self.assertNotEqual(
- original_item.location,
- duplicated_item.location,
+ unicode(original_item.location),
+ unicode(duplicated_item.location),
"Location of duplicate should be different from original"
)
- # Set the location and display name to be the same so we can make sure the rest of the duplicate is equal.
+
+ # Parent will only be equal for root of duplicated structure, in the case
+ # where an item is duplicated in-place.
+ if parent_usage_key and unicode(original_item.parent) == unicode(parent_usage_key):
+ self.assertEqual(
+ unicode(parent_usage_key), unicode(duplicated_item.parent),
+ "Parent of duplicate should equal parent of source for root xblock when duplicated in-place"
+ )
+ else:
+ self.assertNotEqual(
+ unicode(original_item.parent), unicode(duplicated_item.parent),
+ "Parent duplicate should be different from source"
+ )
+
+ # Set the location, display name, and parent to be the same so we can make sure the rest of the
+ # duplicate is equal.
duplicated_item.location = original_item.location
duplicated_item.display_name = original_item.display_name
+ duplicated_item.parent = original_item.parent
# Children will also be duplicated, so for the purposes of testing equality, we will set
# the children to the original after recursively checking the children.
@@ -1367,6 +1404,7 @@ class TestXBlockInfo(ItemTest):
include_children_predicate=ALWAYS,
include_ancestor_info=True
)
+ add_container_page_publishing_info(vertical, xblock_info)
self.validate_vertical_xblock_info(xblock_info)
def test_component_xblock_info(self):
@@ -1487,10 +1525,6 @@ class TestXBlockInfo(ItemTest):
)
else:
self.assertIsNone(xblock_info.get('child_info', None))
- if xblock_info['category'] == 'vertical' and not course_outline:
- self.assertEqual(xblock_info['edited_by'], 'testuser')
- else:
- self.assertIsNone(xblock_info.get('edited_by', None))
class TestLibraryXBlockInfo(ModuleStoreTestCase):
@@ -1595,7 +1629,8 @@ class TestXBlockPublishingInfo(ItemTest):
)
if staff_only:
self._enable_staff_only(child.location)
- return child
+ # In case the staff_only state was set, return the updated xblock.
+ return modulestore().get_item(child.location)
def _get_child_xblock_info(self, xblock_info, index):
"""
@@ -1684,12 +1719,6 @@ class TestXBlockPublishingInfo(ItemTest):
"""
self._verify_xblock_info_state(xblock_info, 'has_explicit_staff_lock', expected_state, path, should_equal)
- def _verify_staff_lock_from_state(self, xblock_info, expected_state, path=None, should_equal=True):
- """
- Verify the staff_lock_from state of an item in the xblock_info.
- """
- self._verify_xblock_info_state(xblock_info, 'staff_lock_from', expected_state, path, should_equal)
-
def test_empty_chapter(self):
empty_chapter = self._create_child(self.course, 'chapter', "Empty Chapter")
xblock_info = self._get_xblock_info(empty_chapter.location)
@@ -1779,7 +1808,7 @@ class TestXBlockPublishingInfo(ItemTest):
"""
chapter = self._create_child(self.course, 'chapter', "Test Chapter", staff_only=True)
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
- self._create_child(sequential, 'vertical', "Unit")
+ vertical = self._create_child(sequential, 'vertical', "Unit")
xblock_info = self._get_xblock_info(chapter.location)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH)
@@ -1789,7 +1818,9 @@ class TestXBlockPublishingInfo(ItemTest):
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_SUBSECTION_PATH)
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_UNIT_PATH)
- self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(chapter), path=self.FIRST_UNIT_PATH)
+ vertical_info = self._get_xblock_info(vertical.location)
+ add_container_page_publishing_info(vertical, vertical_info)
+ self.assertEqual(_xblock_type_and_display_name(chapter), vertical_info["staff_lock_from"])
def test_no_staff_only_section(self):
"""
@@ -1810,7 +1841,7 @@ class TestXBlockPublishingInfo(ItemTest):
"""
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
sequential = self._create_child(chapter, 'sequential', "Test Sequential", staff_only=True)
- self._create_child(sequential, 'vertical', "Unit")
+ vertical = self._create_child(sequential, 'vertical', "Unit")
xblock_info = self._get_xblock_info(chapter.location)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH)
@@ -1820,7 +1851,9 @@ class TestXBlockPublishingInfo(ItemTest):
self._verify_explicit_staff_lock_state(xblock_info, True, path=self.FIRST_SUBSECTION_PATH)
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_UNIT_PATH)
- self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(sequential), path=self.FIRST_UNIT_PATH)
+ vertical_info = self._get_xblock_info(vertical.location)
+ add_container_page_publishing_info(vertical, vertical_info)
+ self.assertEqual(_xblock_type_and_display_name(sequential), vertical_info["staff_lock_from"])
def test_no_staff_only_subsection(self):
"""
@@ -1838,7 +1871,7 @@ class TestXBlockPublishingInfo(ItemTest):
def test_staff_only_unit(self):
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
- unit = self._create_child(sequential, 'vertical', "Unit", staff_only=True)
+ vertical = self._create_child(sequential, 'vertical', "Unit", staff_only=True)
xblock_info = self._get_xblock_info(chapter.location)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only)
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH)
@@ -1848,7 +1881,9 @@ class TestXBlockPublishingInfo(ItemTest):
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_SUBSECTION_PATH)
self._verify_explicit_staff_lock_state(xblock_info, True, path=self.FIRST_UNIT_PATH)
- self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(unit), path=self.FIRST_UNIT_PATH)
+ vertical_info = self._get_xblock_info(vertical.location)
+ add_container_page_publishing_info(vertical, vertical_info)
+ self.assertEqual(_xblock_type_and_display_name(vertical), vertical_info["staff_lock_from"])
def test_unscheduled_section_with_live_subsection(self):
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 04dc2b699a..3aea5b983b 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -37,6 +37,7 @@ from path import path
from warnings import simplefilter
from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin
+from cms.lib.xblock.authoring_mixin import AuthoringMixin
import dealer.git
from xmodule.modulestore.edit_info import EditInfoMixin
@@ -121,6 +122,12 @@ FEATURES = {
# for consistency in user-experience, keep the value of this feature flag
# in sync with the one in lms/envs/common.py
'ENABLE_EDXNOTES': False,
+
+ # Enable support for content libraries. Note that content libraries are
+ # only supported in courses using split mongo. Change the setting
+ # DEFAULT_STORE_FOR_NEW_COURSE to be 'split' to have future courses
+ # and libraries created with split.
+ 'ENABLE_CONTENT_LIBRARIES': False,
}
ENABLE_JASMINE = False
@@ -269,7 +276,13 @@ from xmodule.x_module import XModuleMixin
# This should be moved into an XBlock Runtime/Application object
# once the responsibility of XBlock creation is moved out of modulestore - cpennington
-XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin, EditInfoMixin)
+XBLOCK_MIXINS = (
+ LmsBlockMixin,
+ InheritanceMixin,
+ XModuleMixin,
+ EditInfoMixin,
+ AuthoringMixin,
+)
# Allow any XBlock in Studio
# You should also enable the ALLOW_ALL_ADVANCED_COMPONENTS feature flag, so that
diff --git a/cms/lib/xblock/authoring_mixin.py b/cms/lib/xblock/authoring_mixin.py
new file mode 100644
index 0000000000..a4bbf472e8
--- /dev/null
+++ b/cms/lib/xblock/authoring_mixin.py
@@ -0,0 +1,49 @@
+"""
+Mixin class that provides authoring capabilities for XBlocks.
+"""
+
+import logging
+
+from xblock.core import XBlock
+from xblock.fields import XBlockMixin
+from xblock.fragment import Fragment
+
+log = logging.getLogger(__name__)
+
+VISIBILITY_VIEW = 'visibility_view'
+
+
+@XBlock.needs("i18n")
+class AuthoringMixin(XBlockMixin):
+ """
+ Mixin class that provides authoring capabilities for XBlocks.
+ """
+ _services_requested = {
+ 'i18n': 'need',
+ }
+
+ def _get_studio_resource_url(self, relative_url):
+ """
+ Returns the Studio URL to a static resource.
+ """
+ # TODO: is there a cleaner way to do this?
+ from cms.envs.common import STATIC_URL
+ return STATIC_URL + relative_url
+
+ def visibility_view(self, _context=None):
+ """
+ Render the view to manage an xblock's visibility settings in Studio.
+ Args:
+ _context: Not actively used for this view.
+ Returns:
+ (Fragment): An HTML fragment for editing the visibility of this XBlock.
+ """
+ fragment = Fragment()
+ from contentstore.utils import reverse_course_url
+ fragment.add_content(self.system.render_template('visibility_editor.html', {
+ 'xblock': self,
+ 'manage_groups_url': reverse_course_url('group_configurations_list_handler', self.location.course_key),
+ }))
+ fragment.add_javascript_url(self._get_studio_resource_url('/js/xblock/authoring.js'))
+ fragment.initialize_js('VisibilityEditorInit')
+ return fragment
diff --git a/cms/lib/xblock/test/test_authoring_mixin.py b/cms/lib/xblock/test/test_authoring_mixin.py
new file mode 100644
index 0000000000..326b8cc1b8
--- /dev/null
+++ b/cms/lib/xblock/test/test_authoring_mixin.py
@@ -0,0 +1,125 @@
+"""
+Tests for the Studio authoring XBlock mixin.
+"""
+
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+from xmodule.partitions.partitions import Group, UserPartition
+
+
+class AuthoringMixinTestCase(ModuleStoreTestCase):
+ """
+ Tests the studio authoring XBlock mixin.
+ """
+ def setUp(self):
+ """
+ Create a simple course with a video component.
+ """
+ super(AuthoringMixinTestCase, self).setUp()
+ self.course = CourseFactory.create()
+ chapter = ItemFactory.create(
+ category='chapter',
+ parent_location=self.course.location,
+ display_name='Test Chapter'
+ )
+ sequential = ItemFactory.create(
+ category='sequential',
+ parent_location=chapter.location,
+ display_name='Test Sequential'
+ )
+ self.vertical = ItemFactory.create(
+ category='vertical',
+ parent_location=sequential.location,
+ display_name='Test Vertical'
+ )
+ self.video = ItemFactory.create(
+ category='video',
+ parent_location=self.vertical.location,
+ display_name='Test Vertical'
+ )
+ self.pet_groups = [Group(1, 'Cat Lovers'), Group(2, 'Dog Lovers')]
+
+ def create_content_groups(self, content_groups):
+ """
+ Create a cohorted user partition with the specified content groups.
+ """
+ # pylint: disable=attribute-defined-outside-init
+ self.content_partition = UserPartition(
+ 1,
+ 'Content Groups',
+ 'Contains Groups for Cohorted Courseware',
+ content_groups,
+ scheme_id='cohort'
+ )
+ self.course.user_partitions = [self.content_partition]
+ self.store.update_item(self.course, self.user.id)
+
+ def set_staff_only(self, item):
+ """Make an item visible to staff only."""
+ item.visible_to_staff_only = True
+ self.store.update_item(item, self.user.id)
+
+ def set_group_access(self, item, group_ids):
+ """
+ Set group_access for the specified item to the specified group
+ ids within the content partition.
+ """
+ item.group_access[self.content_partition.id] = group_ids # pylint: disable=no-member
+ self.store.update_item(item, self.user.id)
+
+ def verify_visibility_view_contains(self, item, substrings):
+ """
+ Verify that an item's visibility view returns an html string
+ containing all the expected substrings.
+ """
+ html = item.visibility_view().body_html()
+ for string in substrings:
+ self.assertIn(string, html)
+
+ def test_html_no_partition(self):
+ self.verify_visibility_view_contains(self.video, 'No content groups exist')
+
+ def test_html_empty_partition(self):
+ self.create_content_groups([])
+ self.verify_visibility_view_contains(self.video, 'No content groups exist')
+
+ def test_html_populated_partition(self):
+ self.create_content_groups(self.pet_groups)
+ self.verify_visibility_view_contains(self.video, ['Cat Lovers', 'Dog Lovers'])
+
+ def test_html_no_partition_staff_locked(self):
+ self.set_staff_only(self.vertical)
+ self.verify_visibility_view_contains(self.video, ['No content groups exist'])
+
+ def test_html_empty_partition_staff_locked(self):
+ self.create_content_groups([])
+ self.set_staff_only(self.vertical)
+ self.verify_visibility_view_contains(self.video, 'No content groups exist')
+
+ def test_html_populated_partition_staff_locked(self):
+ self.create_content_groups(self.pet_groups)
+ self.set_staff_only(self.vertical)
+ self.verify_visibility_view_contains(
+ self.video, ['The Unit this component is contained in is hidden from students.', 'Cat Lovers', 'Dog Lovers']
+ )
+
+ def test_html_false_content_group(self):
+ self.create_content_groups(self.pet_groups)
+ self.set_group_access(self.video, ['false_group_id'])
+ self.verify_visibility_view_contains(
+ self.video, ['Cat Lovers', 'Dog Lovers', 'Content group no longer exists.']
+ )
+
+ def test_html_false_content_group_staff_locked(self):
+ self.create_content_groups(self.pet_groups)
+ self.set_staff_only(self.vertical)
+ self.set_group_access(self.video, ['false_group_id'])
+ self.verify_visibility_view_contains(
+ self.video,
+ [
+ 'Cat Lovers',
+ 'Dog Lovers',
+ 'The Unit this component is contained in is hidden from students.',
+ 'Content group no longer exists.'
+ ]
+ )
diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee
index b839ade446..b04495f8c5 100644
--- a/cms/static/coffee/src/views/module_edit.coffee
+++ b/cms/static/coffee/src/views/module_edit.coffee
@@ -52,7 +52,5 @@ define ["jquery", "underscore", "gettext", "xblock/runtime.v1",
clickEditButton: (event) ->
event.preventDefault()
- modal = new EditXBlockModal({
- view: 'student_view'
- });
+ modal = new EditXBlockModal();
modal.edit(this.$el, self.model, { refresh: _.bind(@render, this) })
diff --git a/cms/static/js/factories/container.js b/cms/static/js/factories/container.js
index 429ae58f51..6593c25fb3 100644
--- a/cms/static/js/factories/container.js
+++ b/cms/static/js/factories/container.js
@@ -1,14 +1,14 @@
define([
- 'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/container',
+ 'jquery', 'underscore', 'js/models/xblock_container_info', 'js/views/pages/container',
'js/collections/component_template', 'xmodule', 'coffee/src/main',
'xblock/cms.runtime.v1'
],
-function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
+function($, _, XBlockContainerInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
'use strict';
return function (componentTemplates, XBlockInfoJson, action, options) {
var main_options = {
el: $('#content'),
- model: new XBlockInfo(XBlockInfoJson, {parse: true}),
+ model: new XBlockContainerInfo(XBlockInfoJson, {parse: true}),
action: action,
templates: new ComponentTemplates(componentTemplates, {parse: true})
};
diff --git a/cms/static/js/factories/group_configurations.js b/cms/static/js/factories/group_configurations.js
index 559c4344ee..df892363e5 100644
--- a/cms/static/js/factories/group_configurations.js
+++ b/cms/static/js/factories/group_configurations.js
@@ -1,16 +1,22 @@
define([
- 'js/collections/group_configuration', 'js/views/pages/group_configurations'
-], function(GroupConfigurationCollection, GroupConfigurationsPage) {
+ 'js/collections/group_configuration', 'js/models/group_configuration', 'js/views/pages/group_configurations'
+], function(GroupConfigurationCollection, GroupConfigurationModel, GroupConfigurationsPage) {
'use strict';
- return function (configurations, groupConfigurationUrl, courseOutlineUrl) {
- var collection = new GroupConfigurationCollection(configurations, { parse: true }),
- configurationsPage;
+ return function (experimentsEnabled, experimentGroupConfigurationsJson, contentGroupConfigurationJson,
+ groupConfigurationUrl, courseOutlineUrl) {
+ var experimentGroupConfigurations = new GroupConfigurationCollection(
+ experimentGroupConfigurationsJson, {parse: true}
+ ),
+ contentGroupConfiguration = new GroupConfigurationModel(contentGroupConfigurationJson, {parse: true});
- collection.url = groupConfigurationUrl;
- collection.outlineUrl = courseOutlineUrl;
- configurationsPage = new GroupConfigurationsPage({
+ experimentGroupConfigurations.url = groupConfigurationUrl;
+ experimentGroupConfigurations.outlineUrl = courseOutlineUrl;
+ contentGroupConfiguration.urlRoot = groupConfigurationUrl;
+ new GroupConfigurationsPage({
el: $('#content'),
- collection: collection
+ experimentsEnabled: experimentsEnabled,
+ experimentGroupConfigurations: experimentGroupConfigurations,
+ contentGroupConfiguration: contentGroupConfiguration
}).render();
};
});
diff --git a/cms/static/js/models/custom_sync_xblock_info.js b/cms/static/js/models/custom_sync_xblock_info.js
new file mode 100644
index 0000000000..da3b6b0c7a
--- /dev/null
+++ b/cms/static/js/models/custom_sync_xblock_info.js
@@ -0,0 +1,10 @@
+define(["js/models/xblock_info"],
+ function(XBlockInfo) {
+ var CustomSyncXBlockInfo = XBlockInfo.extend({
+ sync: function(method, model, options) {
+ options.url = (this.urlRoots[method] || this.urlRoot) + '/' + this.get('id');
+ return XBlockInfo.prototype.sync.call(this, method, model, options);
+ }
+ });
+ return CustomSyncXBlockInfo;
+ });
diff --git a/cms/static/js/models/xblock_container_info.js b/cms/static/js/models/xblock_container_info.js
new file mode 100644
index 0000000000..f60f491d38
--- /dev/null
+++ b/cms/static/js/models/xblock_container_info.js
@@ -0,0 +1,9 @@
+define(["js/models/custom_sync_xblock_info"],
+ function(CustomSyncXBlockInfo) {
+ var XBlockContainerInfo = CustomSyncXBlockInfo.extend({
+ urlRoots: {
+ 'read': '/xblock/container'
+ }
+ });
+ return XBlockContainerInfo;
+ });
diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js
index d2324313b5..4e643f7d5f 100644
--- a/cms/static/js/models/xblock_info.js
+++ b/cms/static/js/models/xblock_info.js
@@ -32,7 +32,8 @@ function(Backbone, _, str, ModuleUtils) {
*/
'edited_on':null,
/**
- * User who last edited the xblock or any of its descendants.
+ * User who last edited the xblock or any of its descendants. Will only be present if
+ * publishing info was explicitly requested.
*/
'edited_by':null,
/**
@@ -44,7 +45,8 @@ function(Backbone, _, str, ModuleUtils) {
*/
'published_on': null,
/**
- * User who last published the xblock, or null if never published.
+ * User who last published the xblock, or null if never published. Will only be present if
+ * publishing info was explicitly requested.
*/
'published_by': null,
/**
@@ -70,12 +72,14 @@ function(Backbone, _, str, ModuleUtils) {
/**
* The xblock which is determining the release date. For instance, for a unit,
* this will either be the parent subsection or the grandparent section.
- * This can be null if the release date is unscheduled.
+ * This can be null if the release date is unscheduled. Will only be present if
+ * publishing info was explicitly requested.
*/
'release_date_from':null,
/**
* True if this xblock is currently visible to students. This is computed server-side
- * so that the logic isn't duplicated on the client.
+ * so that the logic isn't duplicated on the client. Will only be present if
+ * publishing info was explicitly requested.
*/
'currently_visible_to_students': null,
/**
@@ -114,13 +118,20 @@ function(Backbone, _, str, ModuleUtils) {
/**
* The xblock which is determining the staff lock value. For instance, for a unit,
* this will either be the parent subsection or the grandparent section.
- * This can be null if the xblock has no inherited staff lock.
+ * This can be null if the xblock has no inherited staff lock. Will only be present if
+ * publishing info was explicitly requested.
*/
'staff_lock_from': null,
/**
* True iff this xblock should display a "Contains staff only content" message.
*/
- 'staff_only_message': null
+ 'staff_only_message': null,
+ /**
+ * True iff this xblock is a unit, and it has children that are only visible to certain
+ * content groups. Note that this is not a recursive property. Will only be present if
+ * publishing info was explicitly requested.
+ */
+ 'has_content_group_components': null
},
initialize: function () {
diff --git a/cms/static/js/models/xblock_outline_info.js b/cms/static/js/models/xblock_outline_info.js
index da34adb219..b90001c98e 100644
--- a/cms/static/js/models/xblock_outline_info.js
+++ b/cms/static/js/models/xblock_outline_info.js
@@ -1,6 +1,6 @@
-define(["js/models/xblock_info"],
- function(XBlockInfo) {
- var XBlockOutlineInfo = XBlockInfo.extend({
+define(["js/models/custom_sync_xblock_info"],
+ function(CustomSyncXBlockInfo) {
+ var XBlockOutlineInfo = CustomSyncXBlockInfo.extend({
urlRoots: {
'read': '/xblock/outline'
@@ -8,15 +8,6 @@ define(["js/models/xblock_info"],
createChild: function(response) {
return new XBlockOutlineInfo(response, { parse: true });
- },
-
- sync: function(method, model, options) {
- var urlRoot = this.urlRoots[method];
- if (!urlRoot) {
- urlRoot = this.urlRoot;
- }
- options.url = urlRoot + '/' + this.get('id');
- return XBlockInfo.prototype.sync.call(this, method, model, options);
}
});
return XBlockOutlineInfo;
diff --git a/cms/static/js/spec/views/group_configuration_spec.js b/cms/static/js/spec/views/group_configuration_spec.js
index 5a23bd1c7b..bc37013475 100644
--- a/cms/static/js/spec/views/group_configuration_spec.js
+++ b/cms/static/js/spec/views/group_configuration_spec.js
@@ -1,17 +1,15 @@
define([
- 'underscore', 'js/models/course', 'js/models/group_configuration',
- 'js/collections/group_configuration',
- 'js/views/group_configuration_details',
- 'js/views/group_configurations_list', 'js/views/group_configuration_edit',
- 'js/views/group_configuration_item', 'js/models/group',
- 'js/collections/group', 'js/views/group_edit',
+ 'underscore', 'js/models/course', 'js/models/group_configuration', 'js/models/group',
+ 'js/collections/group_configuration', 'js/collections/group',
+ 'js/views/group_configuration_details', 'js/views/group_configurations_list', 'js/views/group_configuration_editor',
+ 'js/views/group_configuration_item', 'js/views/experiment_group_edit', 'js/views/content_group_list',
'js/views/feedback_notification', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/spec_helpers/view_helpers', 'jasmine-stealth'
], function(
- _, Course, GroupConfigurationModel, GroupConfigurationCollection,
- GroupConfigurationDetails, GroupConfigurationsList, GroupConfigurationEdit,
- GroupConfigurationItem, GroupModel, GroupCollection, GroupEdit,
- Notification, AjaxHelpers, TemplateHelpers, ViewHelpers
+ _, Course, GroupConfigurationModel, GroupModel, GroupConfigurationCollection, GroupCollection,
+ GroupConfigurationDetailsView, GroupConfigurationsListView, GroupConfigurationEditorView,
+ GroupConfigurationItemView, ExperimentGroupEditView, GroupList, Notification, AjaxHelpers, TemplateHelpers,
+ ViewHelpers
) {
'use strict';
var SELECTORS = {
@@ -26,7 +24,7 @@ define([
groupsAllocation: '.group-allocation',
errorMessage: '.group-configuration-edit-error',
inputGroupName: '.group-name',
- inputName: '.group-configuration-name-input',
+ inputName: '.collection-name-input',
inputDescription: '.group-configuration-description-input',
usageCount: '.group-configuration-usage-count',
usage: '.group-configuration-usage',
@@ -90,7 +88,7 @@ define([
delete window.course;
});
- describe('GroupConfigurationDetails', function() {
+ describe('Experiment group configurations details view', function() {
beforeEach(function() {
TemplateHelpers.installTemplate('group-configuration-details', true);
@@ -102,7 +100,7 @@ define([
this.collection = new GroupConfigurationCollection([ this.model ]);
this.collection.outlineUrl = '/outline';
- this.view = new GroupConfigurationDetails({
+ this.view = new GroupConfigurationDetailsView({
model: this.model
});
appendSetFixtures(this.view.render().el);
@@ -259,7 +257,7 @@ define([
});
});
- describe('GroupConfigurationEdit', function() {
+ describe('Experiment group configurations editor view', function() {
var setValuesToInputs = function (view, values) {
_.each(values, function (value, selector) {
@@ -272,7 +270,7 @@ define([
beforeEach(function() {
ViewHelpers.installViewTemplates();
TemplateHelpers.installTemplates([
- 'group-configuration-edit', 'group-edit'
+ 'group-configuration-editor', 'group-edit'
]);
this.model = new GroupConfigurationModel({
@@ -283,7 +281,7 @@ define([
});
this.collection = new GroupConfigurationCollection([this.model]);
this.collection.url = '/group_configurations';
- this.view = new GroupConfigurationEdit({
+ this.view = new GroupConfigurationEditorView({
model: this.model
});
appendSetFixtures(this.view.render().el);
@@ -490,15 +488,17 @@ define([
});
});
- describe('GroupConfigurationsList', function() {
- var emptyMessage = 'You haven\'t created any group configurations yet.';
+ describe('Experiment group configurations list view', function() {
+ var emptyMessage = 'You have not created any group configurations yet.';
beforeEach(function() {
- TemplateHelpers.installTemplate('no-group-configurations', true);
+ TemplateHelpers.installTemplates(
+ ['group-configuration-editor', 'group-edit', 'list']
+ );
this.model = new GroupConfigurationModel({ id: 0 });
this.collection = new GroupConfigurationCollection();
- this.view = new GroupConfigurationsList({
+ this.view = new GroupConfigurationsListView({
collection: this.collection
});
appendSetFixtures(this.view.render().el);
@@ -526,20 +526,25 @@ define([
expect(this.view.$el).toContainText(emptyMessage);
expect(this.view.$(SELECTORS.itemView)).not.toExist();
});
+
+ it('can create a new group configuration', function () {
+ this.view.$('.new-button').click();
+ expect($('.group-configuration-edit').length).toBeGreaterThan(0);
+ });
});
});
- describe('GroupConfigurationItem', function() {
+ describe('Experiment group configurations controller view', function() {
var clickDeleteItem;
beforeEach(function() {
TemplateHelpers.installTemplates([
- 'group-configuration-edit', 'group-configuration-details'
+ 'group-configuration-editor', 'group-configuration-details'
], true);
this.model = new GroupConfigurationModel({ id: 0 });
this.collection = new GroupConfigurationCollection([ this.model ]);
this.collection.url = '/group_configurations';
- this.view = new GroupConfigurationItem({
+ this.view = new GroupConfigurationItemView({
model: this.model
});
appendSetFixtures(this.view.render().el);
@@ -547,7 +552,7 @@ define([
clickDeleteItem = function (view, promptSpy) {
view.$('.delete').click();
- ViewHelpers.verifyPromptShowing(promptSpy, /Delete this Group Configuration/);
+ ViewHelpers.verifyPromptShowing(promptSpy, /Delete this group configuration/);
ViewHelpers.confirmPrompt(promptSpy);
ViewHelpers.verifyPromptHidden(promptSpy);
};
@@ -598,7 +603,7 @@ define([
});
});
- describe('GroupEdit', function() {
+ describe('Experiment group configurations group editor view', function() {
beforeEach(function() {
TemplateHelpers.installTemplate('group-edit', true);
@@ -608,7 +613,7 @@ define([
this.collection = new GroupCollection([this.model]);
- this.view = new GroupEdit({
+ this.view = new ExperimentGroupEditView({
model: this.model
});
});
@@ -626,4 +631,211 @@ define([
});
});
});
+
+ describe('Content groups list view', function() {
+ var newGroupCss = '.new-button',
+ addGroupCss = '.action-add',
+ inputCss = '.collection-name-input',
+ saveButtonCss = '.action-primary',
+ cancelButtonCss = '.action-cancel',
+ validationErrorCss = '.content-group-edit-error',
+ scopedGroupSelector, createGroups, renderView, saveOrCancel, editNewGroup, editExistingGroup,
+ verifyEditingGroup, respondToSave, expectGroupsVisible, correctValidationError;
+
+ scopedGroupSelector = function(groupIndex, additionalSelectors) {
+ var groupSelector = '.content-groups-list-item-' + groupIndex;
+ if (additionalSelectors) {
+ return groupSelector + ' ' + additionalSelectors;
+ } else {
+ return groupSelector;
+ }
+ };
+
+ createGroups = function (groupNames) {
+ var groups = new GroupCollection(_.map(groupNames, function (groupName) {
+ return {name: groupName};
+ })),
+ groupConfiguration = new GroupConfigurationModel({
+ id: 0,
+ name: 'Content Group Configuration',
+ groups: groups
+ });
+ groupConfiguration.urlRoot = '/mock_url';
+ return groups;
+ };
+
+ renderView = function(groupNames) {
+ var view = new GroupList({collection: createGroups(groupNames || [])}).render();
+ appendSetFixtures(view.el);
+ return view;
+ };
+
+ saveOrCancel = function(view, options, groupIndex) {
+ if (options.save) {
+ view.$(scopedGroupSelector(groupIndex, saveButtonCss)).click();
+ } else if (options.cancel) {
+ view.$(scopedGroupSelector(groupIndex, cancelButtonCss)).click();
+ }
+ };
+
+ editNewGroup = function(view, options) {
+ var newGroupIndex;
+ if (view.collection.length === 0) {
+ view.$(newGroupCss).click();
+ } else {
+ view.$(addGroupCss).click();
+ }
+ newGroupIndex = view.collection.length - 1;
+ view.$(inputCss).val(options.newName);
+ verifyEditingGroup(view, true, newGroupIndex);
+ saveOrCancel(view, options, newGroupIndex);
+ };
+
+ editExistingGroup = function(view, options) {
+ var groupIndex = options.groupIndex || 0;
+ view.$(scopedGroupSelector(groupIndex, '.edit')).click();
+ view.$(scopedGroupSelector(groupIndex, inputCss)).val(options.newName);
+ saveOrCancel(view, options, groupIndex);
+ };
+
+ verifyEditingGroup = function(view, expectEditing, index) {
+ // Should prevent the user from opening more than one edit
+ // form at a time by removing the add button(s) when
+ // editing a group.
+ index = index || 0;
+ if (expectEditing) {
+ expect(view.$(scopedGroupSelector(index, '.content-group-edit'))).toExist();
+ expect(view.$(newGroupCss)).not.toExist();
+ expect(view.$(addGroupCss)).toHaveClass('is-hidden');
+ } else {
+ expect(view.$('.content-group-edit')).not.toExist();
+ if (view.collection.length === 0) {
+ expect(view.$(newGroupCss)).toExist();
+ expect(view.$(addGroupCss)).not.toExist();
+ } else {
+ expect(view.$(newGroupCss)).not.toExist();
+ expect(view.$(addGroupCss)).not.toHaveClass('is-hidden');
+ }
+ }
+ };
+
+ respondToSave = function(requests, view) {
+ expect(requests.length).toBe(1);
+ expect(requests[0].method).toBe('POST');
+ expect(requests[0].url).toBe('/mock_url/0');
+ AjaxHelpers.respondWithJson(requests, {
+ name: 'Content Group Configuration',
+ groups: view.collection.map(function(groupModel, index) {
+ return _.extend(groupModel.toJSON(), {id: index});
+ })
+ });
+ };
+
+ correctValidationError = function(view, requests, newGroupName) {
+ expect(view.$(validationErrorCss)).toExist();
+ verifyEditingGroup(view, true);
+ view.$(inputCss).val(newGroupName);
+ view.$(saveButtonCss).click();
+ respondToSave(requests, view);
+ expect(view.$(validationErrorCss)).not.toExist();
+ };
+
+ expectGroupsVisible = function(view, groupNames) {
+ _.each(groupNames, function(groupName) {
+ expect(view.$('.content-groups-list-item')).toContainText(groupName);
+ });
+ };
+
+ beforeEach(function() {
+ TemplateHelpers.installTemplates(
+ ['content-group-editor', 'content-group-details', 'list']
+ );
+ });
+
+ it('shows a message when no groups are present', function() {
+ expect(renderView().$('.no-content'))
+ .toContainText('You have not created any content groups yet.');
+ });
+
+ it('can render groups', function() {
+ var groupNames = ['Group 1', 'Group 2', 'Group 3'];
+ renderView(groupNames).$('.content-group-details').each(function(index) {
+ expect($(this)).toContainText(groupNames[index]);
+ });
+ });
+
+ it('can create an initial group and save', function() {
+ var requests = AjaxHelpers.requests(this),
+ newGroupName = 'New Group Name',
+ view = renderView();
+ editNewGroup(view, {newName: newGroupName, save: true});
+ respondToSave(requests, view);
+ verifyEditingGroup(view, false);
+ expectGroupsVisible(view, [newGroupName]);
+ });
+
+ it('can add another group and save', function() {
+ var requests = AjaxHelpers.requests(this),
+ oldGroupName = 'Old Group Name',
+ newGroupName = 'New Group Name',
+ view = renderView([oldGroupName]);
+ editNewGroup(view, {newName: newGroupName, save: true});
+ respondToSave(requests, view);
+ verifyEditingGroup(view, false, 1);
+ expectGroupsVisible(view, [oldGroupName, newGroupName]);
+ });
+
+ it('can cancel adding a group', function() {
+ var requests = AjaxHelpers.requests(this),
+ newGroupName = 'New Group Name',
+ view = renderView();
+ editNewGroup(view, {newName: newGroupName, cancel: true});
+ expect(requests.length).toBe(0);
+ verifyEditingGroup(view, false);
+ expect(view.$()).not.toContainText(newGroupName);
+ });
+
+ it('can cancel editing a group', function() {
+ var requests = AjaxHelpers.requests(this),
+ originalGroupName = 'Original Group Name',
+ view = renderView([originalGroupName]);
+ editExistingGroup(view, {newName: 'New Group Name', cancel: true});
+ verifyEditingGroup(view, false);
+ expect(requests.length).toBe(0);
+ expect(view.collection.at(0).get('name')).toBe(originalGroupName);
+ });
+
+ it('can show and correct a validation error', function() {
+ var requests = AjaxHelpers.requests(this),
+ newGroupName = 'New Group Name',
+ view = renderView();
+ editNewGroup(view, {newName: '', save: true});
+ expect(requests.length).toBe(0);
+ correctValidationError(view, requests, newGroupName);
+ });
+
+ it('can not invalidate an existing content group', function() {
+ var requests = AjaxHelpers.requests(this),
+ oldGroupName = 'Old Group Name',
+ view = renderView([oldGroupName]);
+ editExistingGroup(view, {newName: '', save: true});
+ expect(requests.length).toBe(0);
+ correctValidationError(view, requests, oldGroupName);
+ });
+
+ it('trims whitespace', function() {
+ var requests = AjaxHelpers.requests(this),
+ newGroupName = 'New Group Name',
+ view = renderView();
+ editNewGroup(view, {newName: ' ' + newGroupName + ' ', save: true});
+ respondToSave(requests, view);
+ expect(view.collection.at(0).get('name')).toBe(newGroupName);
+ });
+
+ it('only edits one form at a time', function() {
+ var view = renderView();
+ view.collection.add({name: 'Editing Group', editing: true});
+ verifyEditingGroup(view, true);
+ });
+ });
});
diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js
index 7ef32bb7b7..37025ee838 100644
--- a/cms/static/js/spec/views/pages/container_spec.js
+++ b/cms/static/js/spec/views/pages/container_spec.js
@@ -1,6 +1,6 @@
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/views/pages/paged_container", "js/models/xblock_info"],
+ "js/views/pages/container", "js/views/pages/paged_container", "js/models/xblock_info", "jquery.simulate"],
function ($, _, str, AjaxHelpers, TemplateHelpers, EditHelpers, ContainerPage, PagedContainerPage, XBlockInfo) {
function parameterized_suite(label, global_page_options, fixtures) {
@@ -14,7 +14,9 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
mockBadXBlockContainerXBlockHtml = readFixtures('mock/mock-bad-xblock-container-xblock.underscore'),
mockUpdatedContainerXBlockHtml = readFixtures('mock/mock-updated-container-xblock.underscore'),
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore'),
- PageClass = fixtures.page;
+ mockXBlockVisibilityEditorHtml = readFixtures('mock/mock-xblock-visibility-editor.underscore'),
+ PageClass = fixtures.page,
+ hasVisibilityEditor = fixtures.has_visibility_editor;
beforeEach(function () {
var newDisplayName = 'New Display Name';
@@ -219,6 +221,26 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
});
expect(EditHelpers.isShowingModal()).toBeTruthy();
});
+
+ it('can show a visibility modal for a child xblock if supported for the page', function() {
+ var visibilityButtons;
+ renderContainerPage(this, mockContainerXBlockHtml);
+ visibilityButtons = containerPage.$('.wrapper-xblock .visibility-button');
+ if (hasVisibilityEditor) {
+ expect(visibilityButtons.length).toBe(6);
+ visibilityButtons[0].click();
+ expect(str.startsWith(lastRequest().url, '/xblock/locator-component-A1/visibility_view'))
+ .toBeTruthy();
+ AjaxHelpers.respondWithJson(requests, {
+ html: mockXBlockVisibilityEditorHtml,
+ resources: []
+ });
+ expect(EditHelpers.isShowingModal()).toBeTruthy();
+ }
+ else {
+ expect(visibilityButtons.length).toBe(0);
+ }
+ });
});
describe("Editing an xmodule", function () {
@@ -572,19 +594,25 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
});
}
+ // Create a suite for a non-paged container that includes 'edit visibility' buttons
parameterized_suite("Non paged",
{ },
{
page: ContainerPage,
initial: 'mock/mock-container-xblock.underscore',
- add_response: 'mock/mock-xblock.underscore'
+ add_response: 'mock/mock-xblock.underscore',
+ has_visibility_editor: true
}
);
+
+ // Create a suite for a paged container that does not include 'edit visibility' buttons
parameterized_suite("Paged",
{ page_size: 42 },
{
page: PagedContainerPage,
initial: 'mock/mock-container-paged-xblock.underscore',
- add_response: 'mock/mock-xblock-paged.underscore'
- });
+ add_response: 'mock/mock-xblock-paged.underscore',
+ has_visibility_editor: false
+ }
+ );
});
diff --git a/cms/static/js/spec/views/pages/container_subviews_spec.js b/cms/static/js/spec/views/pages/container_subviews_spec.js
index 2809c779a4..8ac10470ed 100644
--- a/cms/static/js/spec/views/pages/container_subviews_spec.js
+++ b/cms/static/js/spec/views/pages/container_subviews_spec.js
@@ -80,7 +80,8 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
describe("PreviewActionController", function () {
var viewPublishedCss = '.button-view',
- previewCss = '.button-preview';
+ previewCss = '.button-preview',
+ visibilityNoteCss = '.note-visibility';
it('renders correctly for unscheduled unit', function () {
renderContainerPage(this, mockContainerXBlockHtml);
@@ -109,6 +110,18 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
fetch({published: false, has_changes: false});
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
});
+
+ it('updates when has_content_group_components attribute changes', function () {
+ renderContainerPage(this, mockContainerXBlockHtml);
+ fetch({has_content_group_components: false});
+ expect(containerPage.$(visibilityNoteCss).length).toBe(0);
+
+ fetch({has_content_group_components: true});
+ expect(containerPage.$(visibilityNoteCss).length).toBe(1);
+
+ fetch({has_content_group_components: false});
+ expect(containerPage.$(visibilityNoteCss).length).toBe(0);
+ });
});
describe("Publisher", function () {
diff --git a/cms/static/js/spec/views/pages/group_configurations_spec.js b/cms/static/js/spec/views/pages/group_configurations_spec.js
index f5eec76c6e..3ebd6f576f 100644
--- a/cms/static/js/spec/views/pages/group_configurations_spec.js
+++ b/cms/static/js/spec/views/pages/group_configurations_spec.js
@@ -1,21 +1,24 @@
define([
'jquery', 'underscore', 'js/views/pages/group_configurations',
- 'js/collections/group_configuration', 'js/common_helpers/template_helpers'
-], function ($, _, GroupConfigurationsPage, GroupConfigurationCollection, TemplateHelpers) {
+ 'js/models/group_configuration', 'js/collections/group_configuration',
+ 'js/common_helpers/template_helpers'
+], function ($, _, GroupConfigurationsPage, GroupConfigurationModel, GroupConfigurationCollection, TemplateHelpers) {
'use strict';
describe('GroupConfigurationsPage', function() {
var mockGroupConfigurationsPage = readFixtures(
'mock/mock-group-configuration-page.underscore'
),
- itemClassName = '.group-configurations-list-item';
+ groupConfigItemClassName = '.group-configurations-list-item';
var initializePage = function (disableSpy) {
var view = new GroupConfigurationsPage({
el: $('#content'),
- collection: new GroupConfigurationCollection({
+ experimentsEnabled: true,
+ experimentGroupConfigurations: new GroupConfigurationCollection({
id: 0,
name: 'Configuration 1'
- })
+ }),
+ contentGroupConfiguration: new GroupConfigurationModel({groups: []})
});
if (!disableSpy) {
@@ -29,15 +32,11 @@ define([
return initializePage().render();
};
- var clickNewConfiguration = function (view) {
- view.$('.nav-actions .new-button').click();
- };
-
beforeEach(function () {
setFixtures(mockGroupConfigurationsPage);
TemplateHelpers.installTemplates([
- 'no-group-configurations', 'group-configuration-edit',
- 'group-configuration-details'
+ 'group-configuration-editor', 'group-configuration-details', 'content-group-details',
+ 'content-group-editor', 'group-edit', 'list'
]);
this.addMatchers({
@@ -52,69 +51,67 @@ define([
var view = initializePage();
expect(view.$('.ui-loading')).toBeVisible();
view.render();
- expect(view.$(itemClassName)).toExist();
+ expect(view.$(groupConfigItemClassName)).toExist();
+ expect(view.$('.content-groups .no-content')).toExist();
expect(view.$('.ui-loading')).toHaveClass('is-hidden');
});
});
- describe('on page close/change', function() {
- it('I see notification message if the model is changed',
- function() {
- var view = initializePage(true),
- message;
-
- view.render();
- message = view.onBeforeUnload();
- expect(message).toBeUndefined();
- });
-
- it('I do not see notification message if the model is not changed',
- function() {
- var expectedMessage ='You have unsaved changes. Do you really want to leave this page?',
- view = renderPage(),
- message;
-
- view.collection.at(0).set('name', 'Configuration 2');
- message = view.onBeforeUnload();
- expect(message).toBe(expectedMessage);
- });
- });
-
- describe('Check that Group Configuration will focus and expand depending on content of url hash', function() {
+ describe('Experiment group configurations', function() {
beforeEach(function () {
spyOn($.fn, 'focus');
TemplateHelpers.installTemplate('group-configuration-details');
this.view = initializePage(true);
});
- it('should focus and expand group configuration if its id is part of url hash', function() {
+ it('should focus and expand if its id is part of url hash', function() {
spyOn(this.view, 'getLocationHash').andReturn('#0');
this.view.render();
// We cannot use .toBeFocused due to flakiness.
expect($.fn.focus).toHaveBeenCalled();
- expect(this.view.$(itemClassName)).toBeExpanded();
+ expect(this.view.$(groupConfigItemClassName)).toBeExpanded();
});
- it('should not focus on any group configuration if url hash is empty', function() {
+ it('should not focus on any experiment configuration if url hash is empty', function() {
spyOn(this.view, 'getLocationHash').andReturn('');
this.view.render();
expect($.fn.focus).not.toHaveBeenCalled();
- expect(this.view.$(itemClassName)).not.toBeExpanded();
+ expect(this.view.$(groupConfigItemClassName)).not.toBeExpanded();
});
- it('should not focus on any group configuration if url hash contains wrong id', function() {
+ it('should not focus on any experiment configuration if url hash contains wrong id', function() {
spyOn(this.view, 'getLocationHash').andReturn('#1');
this.view.render();
expect($.fn.focus).not.toHaveBeenCalled();
- expect(this.view.$(itemClassName)).not.toBeExpanded();
+ expect(this.view.$(groupConfigItemClassName)).not.toBeExpanded();
+ });
+
+ it('should not show a notification message if an experiment configuration is not changed', function () {
+ this.view.render();
+ expect(this.view.onBeforeUnload()).toBeUndefined();
+ });
+
+ it('should show a notification message if an experiment configuration is changed', function () {
+ this.view.experimentGroupConfigurations.at(0).set('name', 'Configuration 2');
+ expect(this.view.onBeforeUnload())
+ .toBe('You have unsaved changes. Do you really want to leave this page?');
});
});
- it('create new group configuration', function () {
- var view = renderPage();
+ describe('Content groups', function() {
+ beforeEach(function() {
+ this.view = renderPage();
+ });
- clickNewConfiguration(view);
- expect($('.group-configuration-edit').length).toBeGreaterThan(0);
+ it('should not show a notification message if a content group is not changed', function () {
+ expect(this.view.onBeforeUnload()).toBeUndefined();
+ });
+
+ it('should show a notification message if a content group is changed', function () {
+ this.view.contentGroupConfiguration.get('groups').add({name: 'Content Group'});
+ expect(this.view.onBeforeUnload())
+ .toBe('You have unsaved changes. Do you really want to leave this page?');
+ });
});
});
});
diff --git a/cms/static/js/spec/views/xblock_editor_spec.js b/cms/static/js/spec/views/xblock_editor_spec.js
index 59116c603b..d681db4b5e 100644
--- a/cms/static/js/spec/views/xblock_editor_spec.js
+++ b/cms/static/js/spec/views/xblock_editor_spec.js
@@ -85,7 +85,7 @@ define([ "jquery", "underscore", "js/common_helpers/ajax_helpers", "js/spec_help
});
// Give the mock xblock a save method...
editor.xblock.save = window.MockDescriptor.save;
- editor.model.save(editor.getXModuleData());
+ editor.model.save(editor.getXBlockFieldData());
request = requests[requests.length - 1];
response = JSON.parse(request.requestBody);
expect(response.metadata.display_name).toBe(testDisplayName);
diff --git a/cms/static/js/views/content_group_details.js b/cms/static/js/views/content_group_details.js
new file mode 100644
index 0000000000..9388db60ed
--- /dev/null
+++ b/cms/static/js/views/content_group_details.js
@@ -0,0 +1,32 @@
+/**
+ * This class defines a simple display view for a content group.
+ * It is expected to be backed by a Group model.
+ */
+define([
+ 'js/views/baseview'
+], function(BaseView) {
+ 'use strict';
+
+ var ContentGroupDetailsView = BaseView.extend({
+ tagName: 'div',
+ className: 'content-group-details collection',
+
+ events: {
+ 'click .edit': 'editGroup'
+ },
+
+ editGroup: function() {
+ this.model.set({'editing': true});
+ },
+
+ initialize: function() {
+ this.template = this.loadTemplate('content-group-details');
+ },
+
+ render: function() {
+ this.$el.html(this.template(this.model.toJSON()));
+ }
+ });
+
+ return ContentGroupDetailsView;
+});
diff --git a/cms/static/js/views/content_group_editor.js b/cms/static/js/views/content_group_editor.js
new file mode 100644
index 0000000000..7d87048f02
--- /dev/null
+++ b/cms/static/js/views/content_group_editor.js
@@ -0,0 +1,44 @@
+/**
+ * This class defines an editing view for content groups.
+ * It is expected to be backed by a Group model.
+ */
+define([
+ 'js/views/list_item_editor', 'underscore'
+],
+function(ListItemEditorView, _) {
+ 'use strict';
+
+ var ContentGroupEditorView = ListItemEditorView.extend({
+ tagName: 'div',
+ className: 'content-group-edit collection-edit',
+ events: {
+ 'submit': 'setAndClose',
+ 'click .action-cancel': 'cancel'
+ },
+
+ initialize: function() {
+ ListItemEditorView.prototype.initialize.call(this);
+ this.template = this.loadTemplate('content-group-editor');
+ },
+
+ getTemplateOptions: function() {
+ return {
+ name: this.model.escape('name'),
+ index: this.model.collection.indexOf(this.model),
+ isNew: this.model.isNew(),
+ uniqueId: _.uniqueId()
+ };
+ },
+
+ setValues: function() {
+ this.model.set({name: this.$('input').val().trim()});
+ return this;
+ },
+
+ getSaveableModel: function() {
+ return this.model.collection.parents[0];
+ }
+ });
+
+ return ContentGroupEditorView;
+});
diff --git a/cms/static/js/views/content_group_item.js b/cms/static/js/views/content_group_item.js
new file mode 100644
index 0000000000..199fe31a0f
--- /dev/null
+++ b/cms/static/js/views/content_group_item.js
@@ -0,0 +1,27 @@
+/**
+ * This class defines an controller view for content groups.
+ * It renders an editor view or a details view depending on the state
+ * of the underlying model.
+ * It is expected to be backed by a Group model.
+ */
+define([
+ 'js/views/list_item', 'js/views/content_group_editor', 'js/views/content_group_details'
+], function(ListItemView, ContentGroupEditorView, ContentGroupDetailsView) {
+ 'use strict';
+
+ var ContentGroupItemView = ListItemView.extend({
+ tagName: 'section',
+
+ baseClassName: 'content-group',
+
+ createEditView: function() {
+ return new ContentGroupEditorView({model: this.model});
+ },
+
+ createDetailsView: function() {
+ return new ContentGroupDetailsView({model: this.model});
+ }
+ });
+
+ return ContentGroupItemView;
+});
diff --git a/cms/static/js/views/content_group_list.js b/cms/static/js/views/content_group_list.js
new file mode 100644
index 0000000000..d4b6242e18
--- /dev/null
+++ b/cms/static/js/views/content_group_list.js
@@ -0,0 +1,26 @@
+/**
+ * This class defines a list view for content groups.
+ * It is expected to be backed by a Group collection.
+ */
+define([
+ 'js/views/list', 'js/views/content_group_item', 'gettext'
+], function(ListView, ContentGroupItemView, gettext) {
+ 'use strict';
+
+ var ContentGroupListView = ListView.extend({
+ tagName: 'div',
+
+ className: 'content-group-list',
+
+ // Translators: This refers to a content group that can be linked to a student cohort.
+ itemCategoryDisplayName: gettext('content group'),
+
+ emptyMessage: gettext('You have not created any content groups yet.'),
+
+ createItemView: function(options) {
+ return new ContentGroupItemView(options);
+ }
+ });
+
+ return ContentGroupListView;
+});
diff --git a/cms/static/js/views/group_edit.js b/cms/static/js/views/experiment_group_edit.js
similarity index 77%
rename from cms/static/js/views/group_edit.js
rename to cms/static/js/views/experiment_group_edit.js
index 9b990d4195..8e3dccea0f 100644
--- a/cms/static/js/views/group_edit.js
+++ b/cms/static/js/views/experiment_group_edit.js
@@ -1,10 +1,14 @@
+/**
+ * This class defines an edit view for groups within content experiment group configurations.
+ * It is expected to be backed by a Group model.
+ */
define([
- 'js/views/baseview', 'underscore', 'underscore.string', 'jquery', 'gettext'
+ 'js/views/baseview', 'underscore', 'underscore.string', 'gettext'
],
-function(BaseView, _, str, $, gettext) {
+function(BaseView, _, str, gettext) {
'use strict';
_.str = str; // used in template
- var GroupEdit = BaseView.extend({
+ var ExperimentGroupEditView = BaseView.extend({
tagName: 'li',
events: {
'click .action-close': 'removeGroup',
@@ -38,7 +42,7 @@ function(BaseView, _, str, $, gettext) {
},
changeName: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
+ if (event && event.preventDefault) { event.preventDefault(); }
this.model.set({
name: this.$('.group-name').val()
}, { silent: true });
@@ -47,7 +51,7 @@ function(BaseView, _, str, $, gettext) {
},
removeGroup: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
+ if (event && event.preventDefault) { event.preventDefault(); }
this.model.collection.remove(this.model);
return this.remove();
},
@@ -65,5 +69,5 @@ function(BaseView, _, str, $, gettext) {
}
});
- return GroupEdit;
+ return ExperimentGroupEditView;
});
diff --git a/cms/static/js/views/group_configuration_details.js b/cms/static/js/views/group_configuration_details.js
index ac73a52610..24fc35dee2 100644
--- a/cms/static/js/views/group_configuration_details.js
+++ b/cms/static/js/views/group_configuration_details.js
@@ -1,9 +1,13 @@
+/**
+ * This class defines a details view for content experiment group configurations.
+ * It is expected to be instantiated with a GroupConfiguration model.
+ */
define([
'js/views/baseview', 'underscore', 'gettext', 'underscore.string'
],
function(BaseView, _, gettext, str) {
'use strict';
- var GroupConfigurationDetails = BaseView.extend({
+ var GroupConfigurationDetailsView = BaseView.extend({
tagName: 'div',
events: {
'click .edit': 'editConfiguration',
@@ -15,6 +19,7 @@ function(BaseView, _, gettext, str) {
var index = this.model.collection.indexOf(this.model);
return [
+ 'collection',
'group-configuration-details',
'group-configuration-details-' + index
].join(' ');
@@ -40,17 +45,17 @@ function(BaseView, _, gettext, str) {
},
editConfiguration: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
+ if (event && event.preventDefault) { event.preventDefault(); }
this.model.set('editing', true);
},
showGroups: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
+ if (event && event.preventDefault) { event.preventDefault(); }
this.model.set('showGroups', true);
},
hideGroups: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
+ if (event && event.preventDefault) { event.preventDefault(); }
this.model.set('showGroups', false);
},
@@ -107,5 +112,5 @@ function(BaseView, _, gettext, str) {
}
});
- return GroupConfigurationDetails;
+ return GroupConfigurationDetailsView;
});
diff --git a/cms/static/js/views/group_configuration_edit.js b/cms/static/js/views/group_configuration_edit.js
deleted file mode 100644
index 3c5c59591f..0000000000
--- a/cms/static/js/views/group_configuration_edit.js
+++ /dev/null
@@ -1,157 +0,0 @@
-define([
- 'js/views/baseview', 'underscore', 'jquery', 'gettext',
- 'js/views/group_edit', 'js/views/utils/view_utils'
-],
-function(BaseView, _, $, gettext, GroupEdit, ViewUtils) {
- 'use strict';
- var GroupConfigurationEdit = BaseView.extend({
- tagName: 'div',
- events: {
- 'change .group-configuration-name-input': 'setName',
- 'change .group-configuration-description-input': 'setDescription',
- "click .action-add-group": "createGroup",
- 'focus .input-text': 'onFocus',
- 'blur .input-text': 'onBlur',
- 'submit': 'setAndClose',
- 'click .action-cancel': 'cancel'
- },
-
- className: function () {
- var index = this.model.collection.indexOf(this.model);
-
- return [
- 'group-configuration-edit',
- 'group-configuration-edit-' + index
- ].join(' ');
- },
-
- initialize: function() {
- var groups;
-
- this.template = this.loadTemplate('group-configuration-edit');
- this.listenTo(this.model, 'invalid', this.render);
- groups = this.model.get('groups');
- this.listenTo(groups, 'add', this.addOne);
- this.listenTo(groups, 'reset', this.addAll);
- this.listenTo(groups, 'all', this.render);
- },
-
- render: function() {
- this.$el.html(this.template({
- id: this.model.get('id'),
- uniqueId: _.uniqueId(),
- name: this.model.escape('name'),
- description: this.model.escape('description'),
- usage: this.model.get('usage'),
- isNew: this.model.isNew(),
- error: this.model.validationError
- }));
- this.addAll();
- return this;
- },
-
- addOne: function(group) {
- var view = new GroupEdit({ model: group });
- this.$('ol.groups').append(view.render().el);
-
- return this;
- },
-
- addAll: function() {
- this.model.get('groups').each(this.addOne, this);
- },
-
- createGroup: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
- var collection = this.model.get('groups');
- collection.add([{
- name: collection.getNextDefaultGroupName(),
- order: collection.nextOrder()
- }]);
- },
-
- setName: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
- this.model.set(
- 'name', this.$('.group-configuration-name-input').val(),
- { silent: true }
- );
- },
-
- setDescription: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
- this.model.set(
- 'description',
- this.$('.group-configuration-description-input').val(),
- { silent: true }
- );
- },
-
- setValues: function() {
- this.setName();
- this.setDescription();
-
- _.each(this.$('.groups li'), function(li, i) {
- var group = this.model.get('groups').at(i);
-
- if(group) {
- group.set({
- 'name': $('.group-name', li).val()
- });
- }
- }, this);
-
- return this;
- },
-
- setAndClose: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
-
- this.setValues();
- if(!this.model.isValid()) {
- return false;
- }
-
- ViewUtils.runOperationShowingMessage(
- gettext('Saving'),
- function () {
- var dfd = $.Deferred();
-
- this.model.save({}, {
- success: function() {
- this.model.setOriginalAttributes();
- this.close();
- dfd.resolve();
- }.bind(this)
- });
-
- return dfd;
- }.bind(this)
- );
- },
-
- cancel: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
-
- this.model.reset();
- return this.close();
- },
-
- close: function() {
- var groupConfigurations = this.model.collection;
-
- this.remove();
- if(this.model.isNew()) {
- // if the group configuration has never been saved, remove it
- groupConfigurations.remove(this.model);
- } else {
- // tell the model that it's no longer being edited
- this.model.set('editing', false);
- }
-
- return this;
- }
- });
-
- return GroupConfigurationEdit;
-});
diff --git a/cms/static/js/views/group_configuration_editor.js b/cms/static/js/views/group_configuration_editor.js
new file mode 100644
index 0000000000..77df7112bd
--- /dev/null
+++ b/cms/static/js/views/group_configuration_editor.js
@@ -0,0 +1,121 @@
+/**
+ * This class defines an editing view for content experiment group configurations.
+ * It is expected to be backed by a GroupConfiguration model.
+ */
+define([
+ 'js/views/list_item_editor', 'underscore', 'jquery', 'gettext',
+ 'js/views/experiment_group_edit'
+],
+function(ListItemEditorView, _, $, gettext, ExperimentGroupEditView) {
+ 'use strict';
+ var GroupConfigurationEditorView = ListItemEditorView.extend({
+ tagName: 'div',
+ events: {
+ 'change .collection-name-input': 'setName',
+ 'change .group-configuration-description-input': 'setDescription',
+ 'click .action-add-group': 'createGroup',
+ 'focus .input-text': 'onFocus',
+ 'blur .input-text': 'onBlur',
+ 'submit': 'setAndClose',
+ 'click .action-cancel': 'cancel'
+ },
+
+ className: function () {
+ var index = this.model.collection.indexOf(this.model);
+
+ return [
+ 'collection-edit',
+ 'group-configuration-edit',
+ 'group-configuration-edit-' + index
+ ].join(' ');
+ },
+
+ initialize: function() {
+ var groups = this.model.get('groups');
+
+ ListItemEditorView.prototype.initialize.call(this);
+
+ this.template = this.loadTemplate('group-configuration-editor');
+ this.listenTo(groups, 'add', this.onAddItem);
+ this.listenTo(groups, 'reset', this.addAll);
+ this.listenTo(groups, 'all', this.render);
+ },
+
+ render: function() {
+ ListItemEditorView.prototype.render.call(this);
+ this.addAll();
+ return this;
+ },
+
+ getTemplateOptions: function() {
+ return {
+ id: this.model.get('id'),
+ uniqueId: _.uniqueId(),
+ name: this.model.escape('name'),
+ description: this.model.escape('description'),
+ usage: this.model.get('usage'),
+ isNew: this.model.isNew()
+ };
+ },
+
+ getSaveableModel: function() {
+ return this.model;
+ },
+
+ onAddItem: function(group) {
+ var view = new ExperimentGroupEditView({ model: group });
+ this.$('ol.groups').append(view.render().el);
+
+ return this;
+ },
+
+ addAll: function() {
+ this.model.get('groups').each(this.onAddItem, this);
+ },
+
+ createGroup: function(event) {
+ if (event && event.preventDefault) { event.preventDefault(); }
+ var collection = this.model.get('groups');
+ collection.add([{
+ name: collection.getNextDefaultGroupName(),
+ order: collection.nextOrder()
+ }]);
+ },
+
+ setName: function(event) {
+ if (event && event.preventDefault) { event.preventDefault(); }
+ this.model.set(
+ 'name', this.$('.collection-name-input').val(),
+ { silent: true }
+ );
+ },
+
+ setDescription: function(event) {
+ if (event && event.preventDefault) { event.preventDefault(); }
+ this.model.set(
+ 'description',
+ this.$('.group-configuration-description-input').val(),
+ { silent: true }
+ );
+ },
+
+ setValues: function() {
+ this.setName();
+ this.setDescription();
+
+ _.each(this.$('.groups li'), function(li, i) {
+ var group = this.model.get('groups').at(i);
+
+ if (group) {
+ group.set({
+ 'name': $('.group-name', li).val()
+ });
+ }
+ }, this);
+
+ return this;
+ }
+ });
+
+ return GroupConfigurationEditorView;
+});
diff --git a/cms/static/js/views/group_configuration_item.js b/cms/static/js/views/group_configuration_item.js
index 2b2e192d1d..a0ba843b8b 100644
--- a/cms/static/js/views/group_configuration_item.js
+++ b/cms/static/js/views/group_configuration_item.js
@@ -1,77 +1,45 @@
+/**
+ * This class defines an controller view for content experiment group configurations.
+ * It renders an editor view or a details view depending on the state
+ * of the underlying model.
+ * It is expected to be backed by a Group model.
+ */
define([
- 'js/views/baseview', 'jquery', "gettext", 'js/views/group_configuration_details',
- 'js/views/group_configuration_edit', "js/views/utils/view_utils"
+ 'js/views/list_item', 'js/views/group_configuration_details', 'js/views/group_configuration_editor', 'gettext'
], function(
- BaseView, $, gettext, GroupConfigurationDetails, GroupConfigurationEdit, ViewUtils
+ ListItemView, GroupConfigurationDetailsView, GroupConfigurationEditorView, gettext
) {
'use strict';
- var GroupConfigurationsItem = BaseView.extend({
+
+ var GroupConfigurationItemView = ListItemView.extend({
+ events: {
+ 'click .delete': 'deleteItem'
+ },
+
tagName: 'section',
+
+ baseClassName: 'group-configuration',
+
+ canDelete: true,
+
+ // Translators: this refers to a collection of groups.
+ itemDisplayName: gettext('group configuration'),
+
attributes: function () {
return {
'id': this.model.get('id'),
'tabindex': -1
};
},
- events: {
- 'click .delete': 'deleteConfiguration'
+
+ createEditView: function() {
+ return new GroupConfigurationEditorView({model: this.model});
},
- className: function () {
- var index = this.model.collection.indexOf(this.model);
-
- return [
- 'group-configuration',
- 'group-configurations-list-item',
- 'group-configurations-list-item-' + index
- ].join(' ');
- },
-
- initialize: function() {
- this.listenTo(this.model, 'change:editing', this.render);
- this.listenTo(this.model, 'remove', this.remove);
- },
-
- deleteConfiguration: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
- var self = this;
- ViewUtils.confirmThenRunOperation(
- gettext('Delete this Group Configuration?'),
- gettext('Deleting this Group Configuration is permanent and cannot be undone.'),
- gettext('Delete'),
- function() {
- return ViewUtils.runOperationShowingMessage(
- gettext('Deleting'),
- function () {
- return self.model.destroy({ wait: true });
- }
- );
- }
- );
- },
-
- render: function() {
- // Removes a view from the DOM, and calls stopListening to remove
- // any bound events that the view has listened to.
- if (this.view) {
- this.view.remove();
- }
-
- if (this.model.get('editing')) {
- this.view = new GroupConfigurationEdit({
- model: this.model
- });
- } else {
- this.view = new GroupConfigurationDetails({
- model: this.model
- });
- }
-
- this.$el.html(this.view.render().el);
-
- return this;
+ createDetailsView: function() {
+ return new GroupConfigurationDetailsView({model: this.model});
}
});
- return GroupConfigurationsItem;
+ return GroupConfigurationItemView;
});
diff --git a/cms/static/js/views/group_configurations_list.js b/cms/static/js/views/group_configurations_list.js
index 9cce9a1435..e40af573e9 100644
--- a/cms/static/js/views/group_configurations_list.js
+++ b/cms/static/js/views/group_configurations_list.js
@@ -1,71 +1,28 @@
+/**
+ * This class defines a list view for content experiment group configurations.
+ * It is expected to be backed by a GroupConfiguration collection.
+ */
define([
- 'js/views/baseview', 'jquery', 'js/views/group_configuration_item'
-], function(
- BaseView, $, GroupConfigurationItemView
-) {
+ 'js/views/list', 'js/views/group_configuration_item', 'gettext'
+], function(ListView, GroupConfigurationItemView, gettext) {
'use strict';
- var GroupConfigurationsList = BaseView.extend({
+
+ var GroupConfigurationsListView = ListView.extend({
tagName: 'div',
+
className: 'group-configurations-list',
- events: {
- 'click .new-button': 'addOne'
- },
- initialize: function() {
- this.emptyTemplate = this.loadTemplate('no-group-configurations');
- this.listenTo(this.collection, 'add', this.addNewItemView);
- this.listenTo(this.collection, 'remove', this.handleDestory);
- },
+ newModelOptions: {addDefaultGroups: true},
- render: function() {
- var configurations = this.collection;
+ // Translators: this refers to a collection of groups.
+ itemCategoryDisplayName: gettext('group configuration'),
- if(configurations.length === 0) {
- this.$el.html(this.emptyTemplate());
- } else {
- var frag = document.createDocumentFragment();
+ emptyMessage: gettext('You have not created any group configurations yet.'),
- configurations.each(function(configuration) {
- var view = new GroupConfigurationItemView({
- model: configuration
- });
-
- frag.appendChild(view.render().el);
- });
-
- this.$el.html([frag]);
- }
-
- return this;
- },
-
- addNewItemView: function (model) {
- var view = new GroupConfigurationItemView({
- model: model
- });
-
- // If items already exist, just append one new. Otherwise, overwrite
- // no-content message.
- if (this.collection.length > 1) {
- this.$el.append(view.render().el);
- } else {
- this.$el.html(view.render().el);
- }
-
- view.$el.focus();
- },
-
- addOne: function(event) {
- if(event && event.preventDefault) { event.preventDefault(); }
- this.collection.add([{ editing: true }]);
- },
-
- handleDestory: function () {
- if(this.collection.length === 0) {
- this.$el.html(this.emptyTemplate());
- }
+ createItemView: function(options) {
+ return new GroupConfigurationItemView(options);
}
});
- return GroupConfigurationsList;
+ return GroupConfigurationsListView;
});
diff --git a/cms/static/js/views/list.js b/cms/static/js/views/list.js
new file mode 100644
index 0000000000..5930329ef9
--- /dev/null
+++ b/cms/static/js/views/list.js
@@ -0,0 +1,99 @@
+/**
+ * A generic list view class.
+ *
+ * Expects the following properties to be overriden:
+ * render when the collection is empty.
+ * - createItemView (function): Create and return an item view for a
+ * model in the collection.
+ * - newModelOptions (object): Options to pass to models which are
+ * added to the collection.
+ * - itemCategoryDisplayName (string): Display name for the category
+ * of items this list contains. For example, 'Group Configuration'.
+ * Note that it must be translated.
+ * - emptyMessage (string): Text to render when the list is empty.
+ */
+define([
+ 'js/views/baseview'
+], function(BaseView) {
+ 'use strict';
+ var ListView = BaseView.extend({
+ events: {
+ 'click .action-add': 'onAddItem',
+ 'click .new-button': 'onAddItem'
+ },
+
+ listContainerCss: '.list-items',
+
+ initialize: function() {
+ this.listenTo(this.collection, 'add', this.addNewItemView);
+ this.listenTo(this.collection, 'remove', this.onRemoveItem);
+ this.template = this.loadTemplate('list');
+
+ // Don't render the add button when editing a form
+ this.listenTo(this.collection, 'change:editing', this.toggleAddButton);
+ this.listenTo(this.collection, 'add', this.toggleAddButton);
+ this.listenTo(this.collection, 'remove', this.toggleAddButton);
+ },
+
+ render: function(model) {
+ this.$el.html(this.template({
+ itemCategoryDisplayName: this.itemCategoryDisplayName,
+ emptyMessage: this.emptyMessage,
+ length: this.collection.length,
+ isEditing: model && model.get('editing')
+ }));
+
+ this.collection.each(function(model) {
+ this.$(this.listContainerCss).append(this.createItemView({model: model}).render().el);
+ }, this);
+
+ return this;
+ },
+
+ hideOrShowAddButton: function(shouldShow) {
+ var addButtonCss = '.action-add';
+ if (this.collection.length) {
+ if (shouldShow) {
+ this.$(addButtonCss).removeClass('is-hidden');
+ } else {
+ this.$(addButtonCss).addClass('is-hidden');
+ }
+ }
+ },
+
+ toggleAddButton: function(model) {
+ if (model.get('editing') && this.collection.contains(model)) {
+ this.hideOrShowAddButton(false);
+ } else {
+ this.hideOrShowAddButton(true);
+ }
+ },
+
+ addNewItemView: function (model) {
+ var view = this.createItemView({model: model});
+
+ // If items already exist, just append one new.
+ // Otherwise re-render the empty list HTML.
+ if (this.collection.length > 1) {
+ this.$(this.listContainerCss).append(view.render().el);
+ } else {
+ this.render();
+ }
+
+ view.$el.focus();
+ },
+
+ onAddItem: function(event) {
+ if (event && event.preventDefault) { event.preventDefault(); }
+ this.collection.add({editing: true}, this.newModelOptions);
+ },
+
+ onRemoveItem: function () {
+ if (this.collection.length === 0) {
+ this.render();
+ }
+ }
+ });
+
+ return ListView;
+});
diff --git a/cms/static/js/views/list_item.js b/cms/static/js/views/list_item.js
new file mode 100644
index 0000000000..a4bf11eeff
--- /dev/null
+++ b/cms/static/js/views/list_item.js
@@ -0,0 +1,90 @@
+/**
+ * A generic view to represent an editable item in a list. The item
+ * has a edit view and a details view.
+ *
+ * Subclasses must implement:
+ * - itemDisplayName (string): Display name for the list item.
+ * Must be translated.
+ * - baseClassName (string): CSS class name representing the item.
+ * - createEditView (function): Render and append the edit view to the
+ * DOM.
+ * - createDetailsView (function): Render and append the details view
+ * to the DOM.
+ */
+define([
+ 'js/views/baseview', 'jquery', "gettext", "js/views/utils/view_utils"
+], function(
+ BaseView, $, gettext, ViewUtils
+) {
+ 'use strict';
+
+ var ListItemView = BaseView.extend({
+ canDelete: false,
+
+ initialize: function() {
+ this.listenTo(this.model, 'change:editing', this.render);
+ this.listenTo(this.model, 'remove', this.remove);
+ },
+
+ className: function () {
+ var index = this.model.collection.indexOf(this.model);
+
+ return [
+ 'wrapper-collection',
+ 'wrapper-collection-' + index,
+ this.baseClassName,
+ this.baseClassName + 's-list-item',
+ this.baseClassName + 's-list-item-' + index
+ ].join(' ');
+ },
+
+ deleteItem: function(event) {
+ if (event && event.preventDefault) { event.preventDefault(); }
+ if (!this.canDelete) { return; }
+ var model = this.model,
+ itemDisplayName = this.itemDisplayName;
+ ViewUtils.confirmThenRunOperation(
+ interpolate(
+ // Translators: "item_display_name" is the name of the item to be deleted.
+ gettext('Delete this %(item_display_name)s?'),
+ {item_display_name: itemDisplayName}, true
+ ),
+ interpolate(
+ // Translators: "item_display_name" is the name of the item to be deleted.
+ gettext('Deleting this %(item_display_name)s is permanent and cannot be undone.'),
+ {item_display_name: itemDisplayName},
+ true
+ ),
+ gettext('Delete'),
+ function() {
+ return ViewUtils.runOperationShowingMessage(
+ gettext('Deleting'),
+ function () {
+ return model.destroy({wait: true});
+ }
+ );
+ }
+ );
+ },
+
+ render: function() {
+ // Removes a view from the DOM, and calls stopListening to remove
+ // any bound events that the view has listened to.
+ if (this.view) {
+ this.view.remove();
+ }
+
+ if (this.model.get('editing')) {
+ this.view = this.createEditView();
+ } else {
+ this.view = this.createDetailsView();
+ }
+
+ this.$el.html(this.view.render().el);
+
+ return this;
+ }
+ });
+
+ return ListItemView;
+});
diff --git a/cms/static/js/views/list_item_editor.js b/cms/static/js/views/list_item_editor.js
new file mode 100644
index 0000000000..29638082bf
--- /dev/null
+++ b/cms/static/js/views/list_item_editor.js
@@ -0,0 +1,76 @@
+/**
+ * A generic view to represent a list item in its editing state.
+ *
+ * Subclasses must implement:
+ * - getTemplateOptions (function): Return an object to pass to the
+ * template.
+ * - setValues (function): Set values on the model according to the
+ * DOM.
+ * - getSaveableModel (function): Return the model which should be
+ * saved by this view. Note this may be a parent model.
+ */
+define([
+ 'js/views/baseview', 'js/views/utils/view_utils', 'underscore', 'gettext'
+], function(BaseView, ViewUtils, _, gettext) {
+ 'use strict';
+
+ var ListItemEditorView = BaseView.extend({
+ initialize: function() {
+ this.listenTo(this.model, 'invalid', this.render);
+ },
+
+ render: function() {
+ this.$el.html(this.template(_.extend({
+ error: this.model.validationError
+ }, this.getTemplateOptions())));
+ },
+
+ setAndClose: function(event) {
+ if (event && event.preventDefault) { event.preventDefault(); }
+
+ this.setValues();
+ if (!this.model.isValid()) {
+ return false;
+ }
+
+ ViewUtils.runOperationShowingMessage(
+ gettext('Saving'),
+ function () {
+ var dfd = $.Deferred();
+ var actionableModel = this.getSaveableModel();
+
+ actionableModel.save({}, {
+ success: function() {
+ actionableModel.setOriginalAttributes();
+ this.close();
+ dfd.resolve();
+ }.bind(this)
+ });
+
+ return dfd;
+ }.bind(this));
+ },
+
+ cancel: function(event) {
+ if (event && event.preventDefault) { event.preventDefault(); }
+
+ this.getSaveableModel().reset();
+ return this.close();
+ },
+
+ close: function() {
+ this.remove();
+ if (this.model.isNew() && !_.isUndefined(this.model.collection)) {
+ // if the item has never been saved, remove it
+ this.model.collection.remove(this.model);
+ } else {
+ // tell the model that it's no longer being edited
+ this.model.set('editing', false);
+ }
+
+ return this;
+ }
+ });
+
+ return ListItemEditorView;
+});
diff --git a/cms/static/js/views/metadata.js b/cms/static/js/views/metadata.js
index f70f77b745..3159ada436 100644
--- a/cms/static/js/views/metadata.js
+++ b/cms/static/js/views/metadata.js
@@ -49,7 +49,7 @@ function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog, V
},
/**
- * Returns the just the modified metadata values, in the format used to persist to the server.
+ * Returns just the modified metadata values, in the format used to persist to the server.
*/
getModifiedMetadataValues: function () {
var modified_values = {};
diff --git a/cms/static/js/views/modals/base_modal.js b/cms/static/js/views/modals/base_modal.js
index eb543295ed..fb02299484 100644
--- a/cms/static/js/views/modals/base_modal.js
+++ b/cms/static/js/views/modals/base_modal.js
@@ -1,5 +1,23 @@
/**
* This is a base modal implementation that provides common utilities.
+ *
+ * A modal implementation should override the following methods:
+ *
+ * getTitle():
+ * returns the title for the modal.
+ * getHTMLContent():
+ * returns the HTML content to be shown inside the modal.
+ *
+ * A modal implementation should also provide the following options:
+ *
+ * modalName: A string identifying the modal.
+ * modalType: A string identifying the type of the modal.
+ * modalSize: A string, either 'sm', 'med', or 'lg' indicating the
+ * size of the modal.
+ * viewSpecificClasses: A string of CSS classes to be attached to
+ * the modal window.
+ * addSaveButton: A boolean indicating whether to include a save
+ * button on the modal.
*/
define(["jquery", "underscore", "gettext", "js/views/baseview"],
function($, _, gettext, BaseView) {
@@ -41,7 +59,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
name: this.options.modalName,
type: this.options.modalType,
size: this.options.modalSize,
- title: this.options.title,
+ title: this.getTitle(),
viewSpecificClasses: this.options.viewSpecificClasses
}));
this.addActionButtons();
@@ -49,6 +67,10 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
this.parentElement.append(this.$el);
},
+ getTitle: function() {
+ return this.options.title;
+ },
+
renderContents: function() {
var contentHtml = this.getContentHtml();
this.$('.modal-content').html(contentHtml);
diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js
index 65d97ad636..104ef571cb 100644
--- a/cms/static/js/views/modals/edit_xblock.js
+++ b/cms/static/js/views/modals/edit_xblock.js
@@ -6,6 +6,8 @@
define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/views/utils/view_utils",
"js/models/xblock_info", "js/views/xblock_editor"],
function($, _, gettext, BaseModal, ViewUtils, XBlockInfo, XBlockEditorView) {
+ "strict mode";
+
var EditXBlockModal = BaseModal.extend({
events : {
"click .action-save": "save",
@@ -15,7 +17,10 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
options: $.extend({}, BaseModal.prototype.options, {
modalName: 'edit-xblock',
addSaveButton: true,
- viewSpecificClasses: 'modal-editor confirm'
+ view: 'studio_view',
+ viewSpecificClasses: 'modal-editor confirm',
+ // Translators: "title" is the name of the current component being edited.
+ titleFormat: gettext("Editing: %(title)s")
}),
initialize: function() {
@@ -56,7 +61,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
displayXBlock: function() {
this.editorView = new XBlockEditorView({
el: this.$('.xblock-editor'),
- model: this.xblockInfo
+ model: this.xblockInfo,
+ view: this.options.view
});
this.editorView.render({
success: _.bind(this.onDisplayXBlock, this)
@@ -66,7 +72,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
onDisplayXBlock: function() {
var editorView = this.editorView,
title = this.getTitle(),
- readOnlyView = (this.editOptions && this.editOptions.readOnlyView) || !editorView.xblock.save;
+ readOnlyView = (this.editOptions && this.editOptions.readOnlyView) || !this.canSave();
// Notify the runtime that the modal has been shown
editorView.notifyRuntime('modal-shown', this);
@@ -99,6 +105,10 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
this.resize();
},
+ canSave: function() {
+ return this.editorView.xblock.save || this.editorView.xblock.collectFieldData;
+ },
+
disableSave: function() {
var saveButton = this.getActionButton('save'),
cancelButton = this.getActionButton('cancel');
@@ -112,7 +122,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
if (!displayName) {
displayName = gettext('Component');
}
- return interpolate(gettext("Editing: %(title)s"), { title: displayName }, true);
+ return interpolate(this.options.titleFormat, { title: displayName }, true);
},
addDefaultModes: function() {
@@ -147,7 +157,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
var self = this,
editorView = this.editorView,
xblockInfo = this.xblockInfo,
- data = editorView.getXModuleData();
+ data = editorView.getXBlockFieldData();
event.preventDefault();
if (data) {
ViewUtils.runOperationShowingMessage(gettext('Saving'),
diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js
index 71619551cd..45ef51a75d 100644
--- a/cms/static/js/views/pages/container.js
+++ b/cms/static/js/views/pages/container.js
@@ -15,6 +15,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
events: {
"click .edit-button": "editXBlock",
+ "click .visibility-button": "editVisibilitySettings",
"click .duplicate-button": "duplicateXBlock",
"click .delete-button": "deleteXBlock",
"click .new-component-button": "scrollToNewComponentButtons"
@@ -161,10 +162,10 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
}
},
- editXBlock: function(event) {
+ editXBlock: function(event, options) {
var xblockElement = this.findXBlockElement(event.target),
self = this,
- modal = new EditXBlockModal({ });
+ modal = new EditXBlockModal(options);
event.preventDefault();
modal.edit(xblockElement, this.model, {
@@ -175,6 +176,16 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
});
},
+ editVisibilitySettings: function(event) {
+ this.editXBlock(event, {
+ view: 'visibility_view',
+ // Translators: "title" is the name of the current component being edited.
+ titleFormat: gettext("Editing visibility for: %(title)s"),
+ viewSpecificClasses: '',
+ modalSize: 'med'
+ });
+ },
+
duplicateXBlock: function(event) {
event.preventDefault();
this.duplicateComponent(this.findXBlockElement(event.target));
diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js
index df77759876..441a4d6860 100644
--- a/cms/static/js/views/pages/container_subviews.js
+++ b/cms/static/js/views/pages/container_subviews.js
@@ -100,7 +100,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
onSync: function(model) {
if (ViewUtils.hasChangedAttributes(model, [
- 'has_changes', 'published', 'edited_on', 'edited_by', 'visibility_state', 'has_explicit_staff_lock'
+ 'has_changes', 'published', 'edited_on', 'edited_by', 'visibility_state',
+ 'has_explicit_staff_lock', 'has_content_group_components'
])) {
this.render();
}
@@ -120,7 +121,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
releaseDate: this.model.get('release_date'),
releaseDateFrom: this.model.get('release_date_from'),
hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'),
- staffLockFrom: this.model.get('staff_lock_from')
+ staffLockFrom: this.model.get('staff_lock_from'),
+ hasContentGroupComponents: this.model.get('has_content_group_components')
}));
return this;
diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js
index a812eb7b02..4e815fd08b 100644
--- a/cms/static/js/views/pages/course_outline.js
+++ b/cms/static/js/views/pages/course_outline.js
@@ -26,7 +26,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
});
this.model.on('change', this.setCollapseExpandVisibility, this);
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
- $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden')
+ $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden');
}));
},
diff --git a/cms/static/js/views/pages/group_configurations.js b/cms/static/js/views/pages/group_configurations.js
index 2bce4afe6d..543eb3616e 100644
--- a/cms/static/js/views/pages/group_configurations.js
+++ b/cms/static/js/views/pages/group_configurations.js
@@ -1,21 +1,31 @@
define([
'jquery', 'underscore', 'gettext', 'js/views/pages/base_page',
- 'js/views/group_configurations_list'
+ 'js/views/group_configurations_list', 'js/views/content_group_list'
],
-function ($, _, gettext, BasePage, GroupConfigurationsList) {
+function ($, _, gettext, BasePage, GroupConfigurationsListView, ContentGroupListView) {
'use strict';
var GroupConfigurationsPage = BasePage.extend({
- initialize: function() {
+ initialize: function(options) {
BasePage.prototype.initialize.call(this);
- this.listView = new GroupConfigurationsList({
- collection: this.collection
+ this.experimentsEnabled = options.experimentsEnabled;
+ if (this.experimentsEnabled) {
+ this.experimentGroupConfigurations = options.experimentGroupConfigurations;
+ this.experimentGroupsListView = new GroupConfigurationsListView({
+ collection: this.experimentGroupConfigurations
+ });
+ }
+ this.contentGroupConfiguration = options.contentGroupConfiguration;
+ this.cohortGroupsListView = new ContentGroupListView({
+ collection: this.contentGroupConfiguration.get('groups')
});
},
renderPage: function() {
var hash = this.getLocationHash();
- this.$('.content-primary').append(this.listView.render().el);
- this.addButtonActions();
+ if (this.experimentsEnabled) {
+ this.$('.wrapper-groups.experiment-groups').append(this.experimentGroupsListView.render().el);
+ }
+ this.$('.wrapper-groups.content-groups').append(this.cohortGroupsListView.render().el);
this.addWindowActions();
if (hash) {
// Strip leading '#' to get id string to match
@@ -24,22 +34,17 @@ function ($, _, gettext, BasePage, GroupConfigurationsList) {
return $.Deferred().resolve().promise();
},
- addButtonActions: function () {
- this.$('.nav-actions .new-button').click(function (event) {
- this.listView.addOne(event);
- }.bind(this));
- },
-
addWindowActions: function () {
$(window).on('beforeunload', this.onBeforeUnload.bind(this));
},
onBeforeUnload: function () {
- var dirty = this.collection.find(function(configuration) {
- return configuration.isDirty();
- });
+ var dirty = this.contentGroupConfiguration.isDirty() ||
+ (this.experimentsEnabled && this.experimentGroupConfigurations.find(function(configuration) {
+ return configuration.isDirty();
+ }));
- if(dirty) {
+ if (dirty) {
return gettext('You have unsaved changes. Do you really want to leave this page?');
}
},
@@ -57,7 +62,7 @@ function ($, _, gettext, BasePage, GroupConfigurationsList) {
* @param {String|Number} Id of the group configuration.
*/
expandConfiguration: function (id) {
- var groupConfig = this.collection.findWhere({
+ var groupConfig = this.experimentsEnabled && this.experimentGroupConfigurations.findWhere({
id: parseInt(id)
});
diff --git a/cms/static/js/views/xblock_editor.js b/cms/static/js/views/xblock_editor.js
index 7cdf2de34a..e549fa6b54 100644
--- a/cms/static/js/views/xblock_editor.js
+++ b/cms/static/js/views/xblock_editor.js
@@ -89,17 +89,23 @@ define(["jquery", "underscore", "gettext", "js/views/xblock", "js/views/metadata
},
/**
- * Returns the data saved for the xmodule. Note that this *does not* work for XBlocks.
+ * Returns the updated field data for the xblock. Note that this works for all
+ * XModules as well as for XBlocks that provide a 'collectFieldData' API.
*/
- getXModuleData: function() {
+ getXBlockFieldData: function() {
var xblock = this.xblock,
metadataEditor = this.getMetadataEditor(),
data = null;
- if (xblock.save) {
+ // If the xblock supports returning its field data then collect it
+ if (xblock.collectFieldData) {
+ data = xblock.collectFieldData();
+ // ... else if this is an XModule then call its save method
+ } else if (xblock.save) {
data = xblock.save();
if (metadataEditor) {
data.metadata = _.extend(data.metadata || {}, this.getChangedMetadata());
}
+ // ... else log an error
} else {
console.error('Cannot save xblock as it has no save method');
}
diff --git a/cms/static/js/xblock/authoring.js b/cms/static/js/xblock/authoring.js
new file mode 100644
index 0000000000..dfc8e7cc0d
--- /dev/null
+++ b/cms/static/js/xblock/authoring.js
@@ -0,0 +1,48 @@
+/**
+ * Client-side logic to support XBlock authoring.
+ */
+(function($) {
+ 'use strict';
+
+ function VisibilityEditorView(runtime, element) {
+ this.getGroupAccess = function() {
+ var groupAccess, userPartitionId, selectedGroupIds;
+ if (element.find('.visibility-level-all').prop('checked')) {
+ return {};
+ }
+ userPartitionId = element.find('.wrapper-visibility-specific').data('user-partition-id').toString();
+ selectedGroupIds = [];
+ element.find('.field-visibility-content-group input:checked').each(function(index, input) {
+ selectedGroupIds.push(parseInt($(input).val()));
+ });
+ groupAccess = {};
+ groupAccess[userPartitionId] = selectedGroupIds;
+ return groupAccess;
+ };
+
+ element.find('.field-visibility-level input').change(function(event) {
+ if ($(event.target).hasClass('visibility-level-all')) {
+ element.find('.field-visibility-content-group input').prop('checked', false);
+ }
+ });
+ element.find('.field-visibility-content-group input').change(function(event) {
+ element.find('.visibility-level-all').prop('checked', false);
+ element.find('.visibility-level-specific').prop('checked', true);
+ });
+ }
+
+ VisibilityEditorView.prototype.collectFieldData = function collectFieldData() {
+ return {
+ metadata: {
+ "group_access": this.getGroupAccess()
+ }
+ };
+ };
+
+ function initializeVisibilityEditor(runtime, element) {
+ return new VisibilityEditorView(runtime, element);
+ }
+
+ // XBlock initialization functions must be global
+ window.VisibilityEditorInit = initializeVisibilityEditor;
+})($);
diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss
index d9739ab081..b68e0399f7 100644
--- a/cms/static/sass/_variables.scss
+++ b/cms/static/sass/_variables.scss
@@ -183,6 +183,7 @@ $color-ready: $green;
$color-warning: $orange-l2;
$color-error: $red-l2;
$color-staff-only: $black;
+$color-visibility-set: $black;
$color-heading-base: $gray-d2;
$color-copy-base: $gray-l1;
diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss
index c6425b1144..cb924078e0 100644
--- a/cms/static/sass/elements/_forms.scss
+++ b/cms/static/sass/elements/_forms.scss
@@ -1,7 +1,7 @@
// studio - elements - forms
// ====================
-// Table of Contents
+// Table of Contents
// * +Forms - General
// * +Field - Is Editable
// * +Field - With Error
@@ -12,7 +12,23 @@
// * +Form - Grandfathered
// +Forms - General
-// ====================
+// ====================
+// element-specific utilities
+// --------------------
+// UI: checkbox/radio inputs
+%input-tickable {
+
+ ~ label {
+ color: $color-copy-base;
+ }
+
+ // STATE: checked/selected
+ &:checked ~ label {
+ @extend %t-strong;
+ color: $ui-action-primary-color-focus;
+ }
+}
+
input[type="text"],
input[type="email"],
input[type="password"],
@@ -77,7 +93,7 @@ form {
}
.input-checkbox-checked, .input-checkbox-unchecked {
- width: $baseline;
+ width: ($baseline*0.75);
}
.input-checkbox {
@@ -107,8 +123,18 @@ form {
}
}
+ // CASE: checkbox input
+ .field-checkbox .input-checkbox {
+ @extend %input-tickable;
+ }
+
+ // CASE: radio input
+ .field-radio .input-radio {
+ @extend %input-tickable;
+ }
+
// CASE: file input
- input[type=file] {
+ input[type="file"] {
@extend %t-copy-sub1;
}
diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss
index 978b5e1437..4c8683c1c4 100644
--- a/cms/static/sass/elements/_modal-window.scss
+++ b/cms/static/sass/elements/_modal-window.scss
@@ -52,6 +52,45 @@
}
}
+ // UI: summary messages
+ .summary-message {
+ margin-bottom: $baseline;
+ padding: ($baseline*0.75);
+ background: $gray-d3;
+
+ .icon, .copy {
+ display: inline-block;
+ vertical-align: top;
+ }
+
+ .icon {
+ @extend %t-icon4;
+ @include margin-right($baseline/2);
+ color: $white;
+ }
+
+ .copy {
+ @extend %t-copy-sub1;
+ max-width: 85%;
+ color: $white;
+ }
+ }
+
+ // CASE: Warning summary message
+ .summary-message-warning {
+ border-top: ($baseline/5) solid $color-warning;
+
+ .icon {
+ color: $color-warning;
+ }
+ }
+
+ // visual dividers
+ .divider-visual {
+ margin: ($baseline*0.75) 0;
+ border: ($baseline/20) solid $gray-l4;
+ }
+
// sections within a modal
.modal-section {
margin-bottom: ($baseline*0.75);
@@ -64,11 +103,20 @@
.modal-section-title {
@extend %t-title6;
margin: 0 0 ($baseline/2) 0;
- border-bottom: 1px solid $gray-l4;
+ border-bottom: ($baseline/10) solid $gray-l4;
padding-bottom: ($baseline/4);
color: $gray-d2;
}
+ .modal-subsection-title {
+ @extend %t-title8;
+ @extend %t-strong;
+ margin-bottom: ($baseline/4);
+ text-transform: uppercase;
+ letter-spacing: 0.1;
+ color: $gray-l2;
+ }
+
.modal-section-content {
.list-fields, .list-actions {
@@ -238,143 +286,6 @@
}
}
- // outline: edit item settings
- .wrapper-modal-window-bulkpublish-section,
- .wrapper-modal-window-bulkpublish-subsection,
- .wrapper-modal-window-bulkpublish-unit,
- .course-outline-modal {
-
- .list-fields {
-
- .field {
- display: inline-block;
- vertical-align: top;
- margin-right: ($baseline/2);
- margin-bottom: ($baseline/4);
-
- label {
- @extend %t-copy-sub1;
- @extend %t-strong;
- @include transition(color $tmg-f3 ease-in-out 0s);
- margin: 0 0 ($baseline/4) 0;
-
- &.is-focused {
- color: $blue;
- }
- }
-
-
- input, textarea {
- @extend %t-copy-base;
- @include transition(all $tmg-f2 ease-in-out 0s);
- height: 100%;
- width: 100%;
- padding: ($baseline/2);
-
- // CASE: long length
- &.long {
- width: 100%;
- }
-
- // CASE: short length
- &.short {
- width: 25%;
- }
- }
-
- // CASE: specific release + due times/dates
- .start-date,
- .start-time,
- .due-date,
- .due-time {
- width: ($baseline*7);
- }
-
- .tip {
- @extend %t-copy-sub1;
- @include transition(color, 0.15s, ease-in-out);
- display: block;
- margin-top: ($baseline/4);
- color: $gray-l2;
- }
-
- .tip-warning {
- color: $gray-d2;
- }
- }
-
- // CASE: type-based input
- .field-text {
-
- // TODO: refactor the _forms.scss partial to allow for this area to inherit from it
- label, input, textarea {
- display: block;
- }
- }
-
- // CASE: select input
- .field-select {
-
- .label, .input {
- display: inline-block;
- vertical-align: middle;
- }
-
- .label {
- margin-right: ($baseline/2);
- }
-
- .input {
- width: 100%;
- }
-
- // CASE: checkbox input
- .field-checkbox {
-
- .label, label {
- margin-bottom: 0;
- }
- }
- }
- }
-
-
-
- // UI: grading section
- .edit-settings-grading {
-
- .grading-type {
- margin-bottom: $baseline;
- }
- }
-
- // UI: staff lock section
- .edit-staff-lock {
-
- .checkbox-cosmetic .input-checkbox {
- @extend %cont-text-sr;
-
- // CASE: unchecked
- ~ .tip-warning {
- display: block;
- }
-
- // CASE: checked
- &:checked {
-
- ~ .tip-warning {
- display: none;
- }
- }
- }
-
- // needed to override poorly scoped margin-bottom on any label element in a view (from _forms.scss)
- .checkbox-cosmetic .label {
- margin-bottom: 0;
- }
- }
- }
-
// xblock custom actions
.modal-window .editor-with-buttons {
margin-bottom: ($baseline*3);
@@ -394,7 +305,7 @@
}
- // special overrides for video module editor/hidden tab editors
+ // MODAL TYPE: component - video modal (includes special overrides for xblock-related editing view)
.modal-lg.modal-type-video {
.modal-content {
@@ -517,4 +428,225 @@
opacity: 0.5;
filter: alpha(opacity=50);
}
+
+ // MODAL TYPE: component - visibility modal
+ .xblock-visibility_view {
+
+ .visibility-controls-secondary {
+ max-height: 100%;
+ overflow-y: auto;
+ @include margin(($baseline*0.75), 0, 0, $baseline);
+ }
+
+ .visibility-controls-group {
+ @extend %wipe-last-child;
+ margin-bottom: $baseline;
+ }
+
+ // UI: form fields
+ .list-fields {
+
+ .field {
+ @extend %wipe-last-child;
+ margin-bottom: ($baseline/4);
+
+ label {
+ @extend %t-copy-sub1;
+ }
+ }
+
+ // UI: radio and checkbox inputs
+ .field-radio, .field-checkbox {
+
+ label {
+ @include margin-left($baseline/4);
+ }
+ }
+ }
+
+ // CASE: content group has been removed
+ .field-visibility-content-group.was-removed {
+
+ .input-checkbox:checked ~ label {
+ color: $color-error;
+ }
+
+ .note {
+ @extend %t-copy-sub2;
+ @extend %t-regular;
+ display: block;
+ color: $color-error;
+ }
+ }
+
+ // CASE: no groups configured for visibility
+ .is-not-configured {
+ @extend %no-content;
+ padding: ($baseline);
+ @include text-align(left); // reset for %no-content's default styling
+
+ .title {
+ @extend %t-title6;
+ font-weight: 600; // needed for poorly scoped .title rule in modals
+ margin: 0 0 ($baseline/2) 0; // needed for poorly scoped .title rule in modals
+ }
+
+ .copy {
+ @extend %t-copy-sub1;
+
+ p {
+ @extend %wipe-last-child;
+ margin-bottom: $baseline;
+ }
+ }
+
+ &.has-actions {
+
+ .actions {
+ margin-top: $baseline;
+ }
+
+ .action {
+ @include margin-left(0); // reset for %no-content's default styling
+ }
+ }
+ }
+ }
+
+ // MODAL TYPE: outline - edit item settings
+ .wrapper-modal-window-bulkpublish-section,
+ .wrapper-modal-window-bulkpublish-subsection,
+ .wrapper-modal-window-bulkpublish-unit,
+ .course-outline-modal {
+
+ .list-fields {
+
+ .field {
+ display: inline-block;
+ vertical-align: top;
+ @include margin-right($baseline/2);
+ margin-bottom: ($baseline/4);
+
+ label {
+ @extend %t-copy-sub1;
+ @extend %t-strong;
+ @include transition(color $tmg-f3 ease-in-out 0s);
+ margin: 0 0 ($baseline/4) 0;
+
+ &.is-focused {
+ color: $blue;
+ }
+ }
+
+
+ input, textarea {
+ @extend %t-copy-base;
+ @include transition(all $tmg-f2 ease-in-out 0s);
+ height: 100%;
+ width: 100%;
+ padding: ($baseline/2);
+
+ // CASE: long length
+ &.long {
+ width: 100%;
+ }
+
+ // CASE: short length
+ &.short {
+ width: 25%;
+ }
+ }
+
+ // CASE: specific release + due times/dates
+ .start-date,
+ .start-time,
+ .due-date,
+ .due-time {
+ width: ($baseline*7);
+ }
+
+ .tip {
+ @extend %t-copy-sub1;
+ @include transition(color, 0.15s, ease-in-out);
+ display: block;
+ margin-top: ($baseline/4);
+ color: $gray-l2;
+ }
+
+ .tip-warning {
+ color: $gray-d2;
+ }
+ }
+
+ // CASE: type-based input
+ .field-text {
+
+ // TODO: refactor the _forms.scss partial to allow for this area to inherit from it
+ label, input, textarea {
+ display: block;
+ }
+ }
+
+ // CASE: select input
+ .field-select {
+
+ .label, .input {
+ display: inline-block;
+ vertical-align: middle;
+ }
+
+ .label {
+ @include margin-right($baseline/2);
+ }
+
+ .input {
+ width: 100%;
+ }
+
+ // CASE: checkbox input
+ .field-checkbox {
+
+ .label, label {
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+
+
+
+ // UI: grading section
+ .edit-settings-grading {
+
+ .grading-type {
+ margin-bottom: $baseline;
+ }
+ }
+
+ // UI: staff lock section
+ .edit-staff-lock {
+
+ .checkbox-cosmetic .input-checkbox {
+ @extend %cont-text-sr;
+
+ // CASE: unchecked
+ ~ .tip-warning {
+ display: block;
+ }
+
+ // CASE: checked
+ &:checked {
+
+ ~ .tip-warning {
+ display: none;
+ }
+ }
+ }
+
+ // needed to override poorly scoped margin-bottom on any label element in a view (from _forms.scss)
+ .checkbox-cosmetic .label {
+ margin-bottom: 0;
+ }
+ }
+ }
}
diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss
index 33d4646f0d..186b78a9e6 100644
--- a/cms/static/sass/elements/_xblocks.scss
+++ b/cms/static/sass/elements/_xblocks.scss
@@ -150,42 +150,50 @@
// ====================
- // UI: xblocks - calls-to-action
- .wrapper-xblock .header-actions {
+ .wrapper-xblock {
- .actions-list {
+ // UI: xblocks - calls-to-action
+ .header-actions .actions-list {
@extend %actions-list;
}
- }
- // UI: xblock is collapsible
- .wrapper-xblock.is-collapsible,
- .wrapper-xblock.xblock-type-container {
+ // CASE: xblock is collapsible
+ &.is-collapsible,
+ &.xblock-type-container {
- .icon {
- font-style: normal;
- }
+ .icon {
+ font-style: normal;
+ }
- .expand-collapse {
- @extend %expand-collapse;
- margin: 0 ($baseline/4);
- height: ($baseline*1.25);
- width: $baseline;
+ .expand-collapse {
+ @extend %expand-collapse;
+ margin: 0 ($baseline/4);
+ height: ($baseline*1.25);
+ width: $baseline;
- &:focus {
- outline: 0;
+ &:focus {
+ outline: 0;
+ }
+ }
+
+ .action-view {
+
+ .action-button {
+ transition: none;
+ }
+
+ .action-button-text {
+ padding-right: ($baseline/5);
+ padding-left: 0;
+ }
}
}
- .action-view {
+ // CASE: xblock has specific visibility based on content groups set
+ &.has-group-visibility-set {
- .action-button {
- transition: none;
- }
-
- .action-button-text {
- padding-right: ($baseline/5);
- padding-left: 0;
+ .action-visibility .visibility-button.visibility-button { // needed to cascade in front of overscoped header-actions CSS rule
+ color: $color-visibility-set;
}
}
}
diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss
index af9539c7b7..74a275e424 100644
--- a/cms/static/sass/views/_container.scss
+++ b/cms/static/sass/views/_container.scss
@@ -6,7 +6,20 @@
// ====================
+// view-specific utilities
+// --------------------
+%status-value-base {
+ @extend %t-title7;
+ @extend %t-strong;
+}
+
+%status-value-sub1 {
+ @extend %t-title8;
+ display: block;
+}
+
// UI: container page view
+// --------------------
.view-container {
@extend %two-col-1;
@@ -102,6 +115,7 @@
@extend %t-title8;
}
+ // UI: publishing details/summary
.bit-publishing {
@extend %bar-module;
@@ -159,37 +173,43 @@
.wrapper-release {
.release-date {
- @extend %t-strong;
+ @extend %status-value-base;
}
.release-with {
- @extend %t-title8;
- display: block;
+ @extend %status-value-sub1;
}
}
.wrapper-visibility {
.copy {
- @extend %t-strong;
+ @extend %status-value-base;
margin-bottom: ($baseline/10);
}
.icon {
- margin-left: ($baseline/4);
color: $gray-d1;
}
.inherited-from {
- @extend %t-title8;
- display: block;
+ @extend %status-value-sub1;
}
+ // UI: note about specific access
+ .note-visibility {
+ @extend %status-value-sub1;
+ .icon {
+ @include margin-right($baseline/4);
+ }
+ }
}
.wrapper-pub-actions {
- padding: ($baseline*0.75);
+ border-top: 1px solid $gray-l4;
+ margin-top: ($baseline/2);
+ padding: $baseline ($baseline*0.75) ($baseline*0.75) ($baseline*0.75);
.action-publish {
@extend %btn-primary-blue;
@@ -209,7 +229,6 @@
}
}
}
-
}
// versioning widget
@@ -244,8 +263,7 @@
.wrapper-unit-id, .wrapper-library-id {
.unit-id-value, .library-id-value {
- @extend %cont-text-wrap;
- @extend %t-copy-sub1;
+ @extend %status-value-base;
display: inline-block;
width: 100%;
}
@@ -308,5 +326,3 @@
}
}
}
-
-
diff --git a/cms/static/sass/views/_group-configuration.scss b/cms/static/sass/views/_group-configuration.scss
index 92b973c8b0..ac6168423f 100644
--- a/cms/static/sass/views/_group-configuration.scss
+++ b/cms/static/sass/views/_group-configuration.scss
@@ -19,206 +19,199 @@
text-align: center;
}
- .no-group-configurations-content {
- @extend %ui-well;
- padding: ($baseline*2);
- background-color: $gray-l4;
- text-align: center;
- color: $gray;
+ .no-content {
+ @extend %no-content;
}
- .new-button {
- @extend %t-action3;
- margin-left: $baseline;
+ .wrapper-groups {
+ margin-bottom: ($baseline*1.5);
- .icon {
- margin-right: ($baseline/2);
+ .title {
+ @extend %t-title4;
+ @extend %t-strong;
+ margin-bottom: ($baseline/2);
+ }
+
+ .copy {
+ @extend %t-copy-sub1;
}
}
- .group-configuration {
+ .wrapper-collection {
@extend %ui-window;
position: relative;
outline: none;
- .group-configuration-details {
- .wrapper-group-configuration {
- padding: $baseline ($baseline*1.5);
+ &:hover .collection .actions {
+ opacity: 1.0;
+ }
- .group-configuration-header {
- margin-bottom: 0;
- border-bottom: 0;
- }
+ .collection-details {
+ padding: $baseline ($baseline*1.5);
+ }
- .group-configuration-title {
- @extend %t-title4;
- @extend %t-strong;
- overflow: hidden;
- text-overflow: ellipsis;
- margin-right: ($baseline*14);
+ .collection-header {
+ margin-bottom: 0;
+ border-bottom: 0;
+ padding-bottom: 0;
- .group-toggle {
- display: inline-block;
- padding-left: $baseline;
- color: $black;
+ .title {
+ @extend %cont-truncated;
+ @extend %t-title5;
+ @extend %t-strong;
+ margin-right: ($baseline*14);
+ color: $black;
- &:hover, &:focus {
- color: $blue;
- }
- }
- }
-
- .group-configuration-info {
- @extend %t-copy-sub1;
- color: $gray-l1;
- margin-left: $baseline;
-
- &.group-configuration-info-inline {
- display: table;
- width: 70%;
- margin: ($baseline/4) 0 ($baseline/2) $baseline;
-
- li {
- @include box-sizing(border-box);
- display: table-cell;
- margin-right: 1%;
-
- &.group-configuration-usage-count {
- font-style: italic;
- }
- }
- }
-
- &.group-configuration-info-block {
- li {
- padding: ($baseline/4) 0;
- }
- }
-
- .group-configuration-label {
- text-transform: uppercase;
- }
-
- .group-configuration-description {
- overflow: hidden;
- text-overflow: ellipsis;
- }
- }
-
- .ui-toggle-expansion {
- @include transition(rotate .15s ease-in-out .25s);
- @extend %t-action1;
+ .toggle {
display: inline-block;
- width: ($baseline*0.75);
- vertical-align: baseline;
- margin-left: -$baseline;
- }
+ padding-left: $baseline;
+ color: $black;
- &.is-selectable {
- @extend %ui-fake-link;
-
- &:hover {
+ &:hover, &:focus {
color: $blue;
-
- .ui-toggle-expansion {
- color: $blue;
- }
}
- }
- .groups {
- margin-left: $baseline;
- margin-bottom: ($baseline*0.75);
-
- .group {
- @extend %t-copy-lead1;
- padding: ($baseline/7) 0 ($baseline/4);
- border-top: 1px solid $gray-l4;
- white-space: nowrap;
-
- &:first-child {
- border-top: none;
- }
-
- .group-name {
- overflow: hidden;
- text-overflow: ellipsis;
- display: inline-block;
- vertical-align: middle;
- width: 75%;
- margin-right: 5%;
- }
-
- .group-allocation {
- display: inline-block;
- vertical-align: middle;
- width: 20%;
- color: $gray-l1;
- text-align: right;
- }
- }
- }
-
- .actions {
- @include transition(opacity .15s .25s ease-in-out);
- opacity: 0.0;
- position: absolute;
- top: $baseline;
- right: $baseline;
-
- .action {
+ .ui-toggle-expansion {
+ @include transition(rotate .15s ease-in-out .25s);
+ @extend %t-action1;
display: inline-block;
- vertical-align: middle;
- margin-right: ($baseline/4);
-
- .edit {
- @include blue-button;
- @extend %t-action4;
- }
-
- .delete {
- @extend %ui-btn-non;
-
- &.is-disabled {
- background-color: $gray-l3;
- color: $gray-l6;
- }
- }
+ width: ($baseline*0.75);
+ vertical-align: baseline;
+ margin-left: -$baseline;
}
- }
- }
- .wrapper-group-configuration-usages {
- @extend %t-copy-sub1;
- box-shadow: 0 2px 2px 0 $shadow inset;
- padding: $baseline ($baseline*1.5) $baseline ($baseline*2.5);
- color: $gray-l1;
+ &.is-selectable {
+ @extend %ui-fake-link;
- .group-configuration-usage {
- margin-left: $baseline;
+ &:hover {
+ color: $blue;
- .group-configuration-usage-unit {
- padding: ($baseline/4) 0;
-
- a {
- font-weight: 600;
- }
-
- .fa-warning {
- margin: ($baseline/4) ($baseline/2) 0 ($baseline*1.5);
- color: $orange;
- }
-
- .fa-times-circle {
- margin: ($baseline/4) ($baseline/2) 0 ($baseline*1.5);
- color: $red-l2;
+ .ui-toggle-expansion {
+ color: $blue;
+ }
}
}
}
}
}
- .wrapper-group-configuration-validation {
+ .collection-info {
+ @extend %t-copy-sub1;
+ color: $gray-l1;
+ margin-left: $baseline;
+
+ &.collection-info-inline {
+ display: table;
+ width: 70%;
+ margin: ($baseline/4) 0 ($baseline/2) $baseline;
+
+ li {
+ @include box-sizing(border-box);
+ display: table-cell;
+ margin-right: 1%;
+ padding: ($baseline/4) 0;
+
+ &.collection-usage-count {
+ font-style: italic;
+ }
+ }
+ }
+
+ .collection-label {
+ text-transform: uppercase;
+ }
+
+ .collection-description {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .collection-items {
+ margin-left: $baseline;
+ margin-bottom: ($baseline*0.75);
+
+ .item {
+ @extend %t-copy-lead1;
+ padding: ($baseline/7) 0 ($baseline/4);
+ border-top: 1px solid $gray-l4;
+ white-space: nowrap;
+
+ &:first-child {
+ border-top: none;
+ }
+
+ .name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: inline-block;
+ vertical-align: middle;
+ width: 75%;
+ margin-right: 5%;
+ }
+ }
+ }
+
+ .collection-details {
+
+ .actions {
+ @include transition(opacity .15s .25s ease-in-out);
+ position: absolute;
+ top: $baseline;
+ right: $baseline;
+ opacity: 0.0;
+
+ .action {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: ($baseline/4);
+
+ .edit {
+ @extend %ui-btn-non-blue;
+ }
+
+ .delete {
+ @extend %ui-btn-non;
+
+ &.is-disabled {
+ background-color: $gray-l3;
+ color: $gray-l6;
+ }
+ }
+ }
+ }
+ }
+
+ .collection-references {
+ @extend %t-copy-sub1;
+ box-shadow: 0 2px 2px 0 $shadow inset;
+ padding: $baseline ($baseline*1.5) $baseline ($baseline*2.5);
+ color: $gray-l1;
+
+ .usage {
+ margin-left: $baseline;
+
+ .usage-unit {
+ padding: ($baseline/4) 0;
+
+ a {
+ @extend %t-strong;
+ }
+
+ .fa-warning {
+ margin: ($baseline/4) ($baseline/2) 0 ($baseline*1.5);
+ color: $orange;
+ }
+
+ .fa-times-circle {
+ margin: ($baseline/4) ($baseline/2) 0 ($baseline*1.5);
+ color: $red-l2;
+ }
+ }
+ }
+ }
+
+ .usage-validation {
@extend %t-copy-sub1;
background-color: $gray-l6;
margin-top: $baseline;
@@ -230,17 +223,21 @@
float: left;
}
- .group-configuration-validation-text {
+ .collection-validation-text {
overflow: auto;
}
}
- .group-configuration-edit {
+ .collection-edit {
@include box-sizing(border-box);
border-radius: 2px;
width: 100%;
background: $white;
+ .message {
+ margin-bottom: 0;
+ }
+
.wrapper-form {
padding: $baseline ($baseline*1.5);
}
@@ -253,10 +250,117 @@
color: $gray-l3;
}
- .is-focused .tip{
+ .is-focused .tip {
color: $gray;
}
+
+ .collection-fields {
+ @extend %cont-no-list;
+ margin-bottom: $baseline;
+ }
+
+ .field {
+ margin: 0 0 ($baseline*0.75) 0;
+
+ &:last-child {
+ @extend %wipe-last-child;
+ }
+
+ &.required {
+
+ label {
+ @extend %t-strong;
+ }
+
+ label:after {
+ margin-left: ($baseline/4);
+ content: "*";
+ }
+ }
+
+ label, input, textarea {
+ display: block;
+ }
+
+ textarea {
+ resize: vertical;
+ }
+
+ label {
+ @extend %t-copy-sub1;
+ @include transition(color, 0.15s, ease-in-out);
+ margin: 0 0 ($baseline/4) 0;
+
+ &.is-focused {
+ color: $blue;
+ }
+ }
+
+ //this section is borrowed from _account.scss - we should clean up and unify later
+ input, textarea {
+ @extend %t-copy-base;
+ height: 100%;
+ width: 100%;
+ padding: ($baseline/2);
+
+ &.long {
+ width: 100%;
+ }
+
+ &.short {
+ width: 25%;
+ }
+
+ ::-webkit-input-placeholder {
+ color: $gray-l4;
+ }
+
+ :-moz-placeholder {
+ color: $gray-l3;
+ }
+
+ ::-moz-placeholder {
+ color: $gray-l3;
+ }
+
+ :-ms-input-placeholder {
+ color: $gray-l3;
+ }
+
+ &:focus {
+ + .tip {
+ color: $gray;
+ }
+ }
+ }
+
+ &.error {
+ label {
+ color: $red;
+ }
+
+ input {
+ border-color: $red;
+ }
+ }
+ }
+
+ label.required {
+ @extend %t-strong;
+
+ &:after {
+ margin-left: ($baseline/4);
+ content: "*";
+ }
+ }
+
+ .field.add-collection-name label {
+ @extend %t-title5;
+ display: inline-block;
+ vertical-align: bottom;
+ }
+
.actions {
box-shadow: inset 0 1px 2px $shadow;
border-top: 1px solid $gray-l1;
@@ -273,23 +377,13 @@
// add a group is below with groups styling
.action-primary {
- @include blue-button;
- @include transition(all .15s);
- @extend %t-action2;
- @extend %t-strong;
- display: inline-block;
- padding: ($baseline/5) $baseline;
- text-transform: uppercase;
+ @extend %btn-primary-blue;
+ padding: ($baseline/4) $baseline;
}
.action-secondary {
- @include grey-button;
- @include transition(all .15s);
- @extend %t-action2;
- @extend %t-strong;
- display: inline-block;
- padding: ($baseline/5) $baseline;
- text-transform: uppercase;
+ @extend %btn-secondary-gray;
+ padding: ($baseline/4) $baseline;
}
.wrapper-delete-button {
@@ -311,191 +405,9 @@
@extend %t-strong;
}
}
-
- .groups-fields,
- .group-configuration-fields {
- @extend %cont-no-list;
-
- .field {
- margin: 0 0 ($baseline*0.75) 0;
-
- &:last-child {
- margin-bottom: 0;
- }
-
- &.required {
-
- label {
- @extend %t-strong;
- }
-
- label:after {
- margin-left: ($baseline/4);
- content: "*";
- }
- }
-
- label, input, textarea {
- display: block;
- }
-
- textarea {
- resize: vertical;
- }
-
- label {
- @extend %t-copy-sub1;
- @include transition(color, 0.15s, ease-in-out);
- margin: 0 0 ($baseline/4) 0;
-
- &.is-focused {
- color: $blue;
- }
- }
-
- //this section is borrowed from _account.scss - we should clean up and unify later
- input, textarea {
- @extend %t-copy-base;
- height: 100%;
- width: 100%;
- padding: ($baseline/2);
-
- &.long {
- width: 100%;
- }
-
- &.short {
- width: 25%;
- }
-
- ::-webkit-input-placeholder {
- color: $gray-l4;
- }
-
- :-moz-placeholder {
- color: $gray-l3;
- }
-
- ::-moz-placeholder {
- color: $gray-l3;
- }
-
- :-ms-input-placeholder {
- color: $gray-l3;
- }
-
- &:focus {
- + .tip {
- color: $gray;
- }
- }
- }
-
- &.error {
- label {
- color: $red;
- }
-
- input {
- border-color: $red;
- }
- }
-
- &.add-group-configuration-name label {
- @extend %t-title5;
- display: inline-block;
- width: 50%;
- padding-right: 5%;
- overflow: hidden;
- text-overflow: ellipsis;
- vertical-align: bottom;
- }
-
- .group-configuration-id {
- display: inline-block;
- width: 45%;
- text-align: right;
- vertical-align: top;
- color: $gray-l1;
-
- .group-configuration-value {
- @extend %t-strong;
- white-space: nowrap;
- margin-left: ($baseline*0.5);
- }
- }
-
- &.group-allocation {
- color: $gray-l1;
- }
- }
-
- label.required {
- @extend %t-strong;
-
- &:after {
- margin-left: ($baseline/4);
- content: "*";
- }
- }
-
- .field-group {
- @include clearfix();
- margin: 0 0 ($baseline/2) 0;
- padding: ($baseline/4) 0 0 0;
-
- .group-allocation,
- .field {
- display: inline-block;
- vertical-align: middle;
- margin: 0 3% 0 0;
- }
-
- .group-allocation {
- max-width: 10%;
- min-width: 5%;
- color: $gray-l1;
- }
-
- .field {
- position: relative;
-
- &.long {
- width: 80%;
- }
-
- &.short {
- width: 10%;
- }
- }
-
- .action-close {
- @include transition(color $tmg-f2 ease-in-out);
- @extend %t-action1;
- display: inline-block;
- border: 0;
- padding: 0;
- background: transparent;
- color: $blue-l3;
- vertical-align: middle;
-
- &:hover {
- color: $blue;
- }
- }
- }
- }
-
- .group-configuration-fields {
- margin-bottom: $baseline;
- }
}
- &:hover .wrapper-group-configuration .actions {
- opacity: 1.0;
- }
-
- .action-add-group {
+ .action-add-item {
@extend %ui-btn-flat-outline;
@extend %t-action2;
@extend %t-strong;
@@ -505,6 +417,183 @@
padding: ($baseline/2);
}
}
+
+ // add/new collection
+ .action-add {
+ @extend %ui-btn-flat-outline;
+ display: block;
+ width: 100%;
+ margin-top: ($baseline*0.75);
+ padding: ($baseline/2) $baseline;
+
+ &.is-hidden {
+ display: none;
+ }
+
+ .icon {
+ display: inline-block;
+ vertical-align: middle;
+ @include margin-right($baseline/2);
+ }
+ }
+
+ // specific group-type styles
+ .content-groups {
+
+ .collection-header{
+
+ .title {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .experiment-groups {
+
+ .group-configuration-details {
+
+ .group-configuration-info {
+ @extend %t-copy-sub1;
+ color: $gray-l1;
+ margin-left: $baseline;
+
+ &.group-configuration-info-inline {
+ display: table;
+ width: 70%;
+ margin: ($baseline/4) 0 ($baseline/2) $baseline;
+
+ li {
+ @include box-sizing(border-box);
+ display: table-cell;
+ margin-right: 1%;
+
+ &.group-configuration-usage-count {
+ font-style: italic;
+ }
+ }
+ }
+
+ &.group-configuration-info-block {
+ li {
+ padding: ($baseline/4) 0;
+ }
+ }
+
+ .group-configuration-label {
+ text-transform: uppercase;
+ }
+
+ .group-configuration-description {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ .groups {
+ margin-left: $baseline;
+ margin-bottom: ($baseline*0.75);
+
+ .group {
+ @extend %t-copy-lead1;
+ padding: ($baseline/7) 0 ($baseline/4);
+ border-top: 1px solid $gray-l4;
+ white-space: nowrap;
+
+ &:first-child {
+ border-top: none;
+ }
+
+ .group-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: inline-block;
+ vertical-align: middle;
+ width: 75%;
+ margin-right: 5%;
+ }
+
+ .group-allocation {
+ display: inline-block;
+ vertical-align: middle;
+ width: 20%;
+ color: $gray-l1;
+ text-align: right;
+ }
+ }
+ }
+ }
+
+ .group-configuration-edit {
+
+ .add-collection-name label {
+ width: 50%;
+ padding-right: 5%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ vertical-align: bottom;
+ }
+
+ .group-configuration-id {
+ display: inline-block;
+ width: 45%;
+ text-align: right;
+ vertical-align: top;
+ color: $gray-l1;
+
+ .group-configuration-value {
+ @extend %t-strong;
+ white-space: nowrap;
+ margin-left: ($baseline*0.5);
+ }
+ }
+
+ .field-group {
+ @include clearfix();
+ margin: 0 0 ($baseline/2) 0;
+ padding: ($baseline/4) 0 0 0;
+
+ .group-allocation,
+ .field {
+ display: inline-block;
+ vertical-align: middle;
+ margin: 0 3% 0 0;
+ }
+
+ .group-allocation {
+ max-width: 10%;
+ min-width: 5%;
+ color: $gray-l1;
+ }
+
+ .field {
+ position: relative;
+
+ &.long {
+ width: 80%;
+ }
+
+ &.short {
+ width: 10%;
+ }
+ }
+
+ .action-close {
+ @include transition(color $tmg-f2 ease-in-out);
+ @extend %t-action1;
+ display: inline-block;
+ border: 0;
+ padding: 0;
+ background: transparent;
+ color: $blue-l3;
+ vertical-align: middle;
+
+ &:hover {
+ color: $blue;
+ }
+ }
+ }
+ }
+ }
}
.content-supplementary {
diff --git a/cms/templates/base.html b/cms/templates/base.html
index 4397cfedde..176f9599a1 100644
--- a/cms/templates/base.html
+++ b/cms/templates/base.html
@@ -86,6 +86,9 @@ import json
+
+ <%block name="modal_placeholder">%block>
+
<%block name="jsextra">%block>
@@ -19,11 +20,9 @@
%block>
<%block name="requirejs">
-% if configurations is not None:
require(["js/factories/group_configurations"], function(GroupConfigurationsFactory) {
- GroupConfigurationsFactory(${json.dumps(configurations)}, "${group_configuration_url}", "${course_outline_url}");
+ GroupConfigurationsFactory(${json.dumps(should_show_experiment_groups)}, ${json.dumps(experiment_group_configurations)}, ${json.dumps(content_group_configuration)}, "${group_configuration_url}", "${course_outline_url}");
});
-% endif
%block>
<%block name="content">
@@ -33,45 +32,56 @@
${_("Settings")}
> ${_("Group Configurations")}
-
- % if configurations is None:
-
-
- ${_("This module is disabled at the moment.")}
-
+
+
${_("Content Groups")}
+
- % else:
-
-
${_("Loading")}
+
+ % if should_show_experiment_groups:
+
+
${_("Experiment Group Configurations")}
+ % if experiment_group_configurations is None:
+
+
+ ${_("This module is disabled at the moment.")}
+
+
+ % else:
+
+ % endif
% endif