diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py
index 6f572e9ae614..0a342f08e8d8 100644
--- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py
+++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py
@@ -129,8 +129,8 @@ def edit_component(index=0):
# Verify that the "loading" indication has been hidden.
world.wait_for_loading()
# Verify that the "edit" button is present.
- world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
- world.css_click('a.edit-button', index)
+ world.wait_for(lambda _driver: world.css_visible('.edit-button'))
+ world.css_click('.edit-button', index)
world.wait_for_ajax_complete()
diff --git a/cms/djangoapps/contentstore/features/pages.py b/cms/djangoapps/contentstore/features/pages.py
index bb3e113b643e..f16aec9d6070 100644
--- a/cms/djangoapps/contentstore/features/pages.py
+++ b/cms/djangoapps/contentstore/features/pages.py
@@ -38,7 +38,7 @@ def not_see_any_static_pages(step):
@step(u'I "(edit|delete)" the static page$')
def click_edit_or_delete(step, edit_or_delete):
- button_css = 'ul.component-actions a.%s-button' % edit_or_delete
+ button_css = 'ul.component-actions .%s-button' % edit_or_delete
world.css_click(button_css)
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 8ed409fc8076..f1a5ed149471 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -283,6 +283,23 @@ def reverse_usage_url(handler_name, usage_key, kwargs=None):
return reverse_url(handler_name, 'usage_key_string', usage_key, kwargs)
+def get_group_display_name(user_partitions, xblock_display_name):
+ """
+ Get the group name if matching group xblock is found.
+
+ Arguments:
+ user_partitions (Dict): Locator of source item.
+ xblock_display_name (String): Display name of group xblock.
+
+ Returns:
+ group name (String): Group name of the matching group.
+ """
+ for user_partition in user_partitions:
+ for group in user_partition['groups']:
+ if str(group['id']) in xblock_display_name:
+ return group['name']
+
+
def get_user_partition_info(xblock, schemes=None, course=None):
"""
Retrieve user partition information for an XBlock for display in editors.
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index c33f195a019a..378c4b2fd266 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -20,7 +20,7 @@
from xblock.plugin import PluginMissingError
from xblock.runtime import Mixologist
-from contentstore.utils import get_lms_link_for_item, get_xblock_aside_instance
+from contentstore.utils import get_lms_link_for_item, reverse_course_url, get_xblock_aside_instance
from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name
from contentstore.views.item import create_xblock_info, add_container_page_publishing_info, StudioEditModuleRuntime
@@ -165,6 +165,7 @@ def container_handler(request, usage_key_string):
'subsection': subsection,
'section': section,
'new_unit_category': 'vertical',
+ 'outline_url': '{url}?format=concise'.format(url=reverse_course_url('course_handler', course.id)),
'ancestor_xblocks': ancestor_xblocks,
'component_templates': component_templates,
'xblock_info': xblock_info,
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index df5ba575aea2..2971ffa9a7ba 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -336,11 +336,16 @@ def _course_outline_json(request, course_module):
"""
Returns a JSON representation of the course module and recursively all of its children.
"""
+ is_concise = request.GET.get('format') == 'concise'
+ include_children_predicate = lambda xblock: not xblock.category == 'vertical'
+ if is_concise:
+ include_children_predicate = lambda xblock: xblock.has_children
return create_xblock_info(
course_module,
include_child_info=True,
- course_outline=True,
- include_children_predicate=lambda xblock: not xblock.category == 'vertical',
+ course_outline=False if is_concise else True,
+ include_children_predicate=include_children_predicate,
+ is_concise=is_concise,
user=request.user
)
diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py
index f1789eeff81c..d5e2f7febf6f 100644
--- a/cms/djangoapps/contentstore/views/item.py
+++ b/cms/djangoapps/contentstore/views/item.py
@@ -19,6 +19,8 @@
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryUsageLocator
from pytz import UTC
+
+from xblock.core import XBlock
from xblock.fields import Scope
from xblock.fragment import Fragment
from xblock_django.user_service import DjangoXBlockUserService
@@ -27,7 +29,7 @@
from contentstore.utils import (
find_release_date_source, find_staff_lock_source, is_currently_visible_to_students,
ancestor_has_staff_lock, has_children_visible_to_specific_content_groups,
- get_user_partition_info,
+ get_user_partition_info, get_group_display_name,
)
from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \
xblock_type_display_name, get_parent_xblock, create_xblock, usage_key_with_run
@@ -98,6 +100,7 @@ def xblock_handler(request, usage_key_string):
GET
json: returns representation of the xblock (locator id, data, and metadata).
if ?fields=graderType, it returns the graderType for the unit instead of the above.
+ if ?fields=ancestorInfo, it returns ancestor info of the xblock.
html: returns HTML for rendering the xblock (which includes both the "preview" view and the "editor" view)
PUT or POST or PATCH
json: if xblock locator is specified, update the xblock instance. The json payload can contain
@@ -126,8 +129,11 @@ def xblock_handler(request, usage_key_string):
if usage_key_string is not specified, create a new xblock instance, either by duplicating
an existing xblock, or creating an entirely new one. The json playload can contain
these fields:
- :parent_locator: parent for new xblock, required for both duplicate and create new instance
+ :parent_locator: parent for new xblock, required for duplicate, move and create new instance
:duplicate_source_locator: if present, use this as the source for creating a duplicate copy
+ :move_source_locator: if present, use this as the source item for moving
+ :target_index: if present, use this as the target index for moving an item to a particular index
+ otherwise target_index is calculated. It is sent back in the response.
:category: type of xblock, required if duplicate_source_locator is not present.
:display_name: name for new xblock, optional
:boilerplate: template name for populating fields, optional and only used
@@ -149,6 +155,10 @@ def xblock_handler(request, usage_key_string):
if 'graderType' in fields:
# 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))
+ elif 'ancestorInfo' in fields:
+ xblock = _get_xblock(usage_key, request.user)
+ ancestor_info = _create_xblock_ancestor_info(xblock, is_concise=True)
+ return JsonResponse(ancestor_info)
# TODO: pass fields to _get_module_info and only return those
with modulestore().bulk_operations(usage_key.course_key):
response = _get_module_info(_get_xblock(usage_key, request.user))
@@ -193,14 +203,26 @@ def xblock_handler(request, usage_key_string):
request.user,
request.json.get('display_name'),
)
-
- return JsonResponse({"locator": unicode(dest_usage_key), "courseKey": unicode(dest_usage_key.course_key)})
+ return JsonResponse({'locator': unicode(dest_usage_key), 'courseKey': unicode(dest_usage_key.course_key)})
else:
return _create_item(request)
+ elif request.method == 'PATCH':
+ if 'move_source_locator' in request.json:
+ move_source_usage_key = usage_key_with_run(request.json.get('move_source_locator'))
+ target_parent_usage_key = usage_key_with_run(request.json.get('parent_locator'))
+ target_index = request.json.get('target_index')
+ if (
+ not has_studio_write_access(request.user, target_parent_usage_key.course_key) or
+ not has_studio_read_access(request.user, target_parent_usage_key.course_key)
+ ):
+ raise PermissionDenied()
+ return _move_item(move_source_usage_key, target_parent_usage_key, request.user, target_index)
+
+ return JsonResponse({'error': 'Patch request did not recognise any parameters to handle.'}, status=400)
else:
return HttpResponseBadRequest(
- "Only instance creation is supported without a usage key.",
- content_type="text/plain"
+ 'Only instance creation is supported without a usage key.',
+ content_type='text/plain'
)
@@ -631,10 +653,141 @@ def _create_item(request):
)
return JsonResponse(
- {"locator": unicode(created_block.location), "courseKey": unicode(created_block.location.course_key)}
+ {'locator': unicode(created_block.location), 'courseKey': unicode(created_block.location.course_key)}
)
+def _get_source_index(source_usage_key, source_parent):
+ """
+ Get source index position of the XBlock.
+
+ Arguments:
+ source_usage_key (BlockUsageLocator): Locator of source item.
+ source_parent (XBlock): A parent of the source XBlock.
+
+ Returns:
+ source_index (int): Index position of the xblock in a parent.
+ """
+ try:
+ source_index = source_parent.children.index(source_usage_key)
+ return source_index
+ except ValueError:
+ return None
+
+
+def is_source_item_in_target_parents(source_item, target_parent):
+ """
+ Returns True if source item is found in target parents otherwise False.
+
+ Arguments:
+ source_item (XBlock): Source Xblock.
+ target_parent (XBlock): Target XBlock.
+ """
+ target_ancestors = _create_xblock_ancestor_info(target_parent, is_concise=True)['ancestors']
+ for target_ancestor in target_ancestors:
+ if unicode(source_item.location) == target_ancestor['id']:
+ return True
+ return False
+
+
+def _move_item(source_usage_key, target_parent_usage_key, user, target_index=None):
+ """
+ Move an existing xblock as a child of the supplied target_parent_usage_key.
+
+ Arguments:
+ source_usage_key (BlockUsageLocator): Locator of source item.
+ target_parent_usage_key (BlockUsageLocator): Locator of target parent.
+ target_index (int): If provided, insert source item at provided index location in target_parent_usage_key item.
+
+ Returns:
+ JsonResponse: Information regarding move operation. It may contains error info if an invalid move operation
+ is performed.
+ """
+ # Get the list of all parentable component type XBlocks.
+ parent_component_types = list(
+ set(name for name, class_ in XBlock.load_classes() if getattr(class_, 'has_children', False)) -
+ set(DIRECT_ONLY_CATEGORIES)
+ )
+
+ store = modulestore()
+ with store.bulk_operations(source_usage_key.course_key):
+ source_item = store.get_item(source_usage_key)
+ source_parent = source_item.get_parent()
+ target_parent = store.get_item(target_parent_usage_key)
+ source_type = source_item.category
+ target_parent_type = target_parent.category
+ error = None
+
+ # Store actual/initial index of the source item. This would be sent back with response,
+ # so that with Undo operation, it would easier to move back item to it's original/old index.
+ source_index = _get_source_index(source_usage_key, source_parent)
+
+ valid_move_type = {
+ 'sequential': 'vertical',
+ 'chapter': 'sequential',
+ }
+
+ if (valid_move_type.get(target_parent_type, '') != source_type and
+ target_parent_type not in parent_component_types):
+ error = _('You can not move {source_type} into {target_parent_type}.').format(
+ source_type=source_type,
+ target_parent_type=target_parent_type,
+ )
+ elif source_parent.location == target_parent.location:
+ error = _('You can not move an item into the same parent.')
+ elif source_item.location == target_parent.location:
+ error = _('You can not move an item into itself.')
+ elif is_source_item_in_target_parents(source_item, target_parent):
+ error = _('You can not move an item into it\'s child.')
+ elif target_parent_type == 'split_test':
+ error = _('You can not move an item directly into content experiment.')
+ elif source_index is None:
+ error = _('{source_usage_key} not found in {parent_usage_key}.').format(
+ source_usage_key=unicode(source_usage_key),
+ parent_usage_key=unicode(source_parent.location)
+ )
+ else:
+ try:
+ target_index = int(target_index) if target_index is not None else None
+ if len(target_parent.children) < target_index:
+ error = _('You can not move {source_usage_key} at an invalid index ({target_index}).').format(
+ source_usage_key=unicode(source_usage_key),
+ target_index=target_index
+ )
+ except ValueError:
+ error = _('You must provide target_index ({target_index}) as an integer.').format(
+ target_index=target_index
+ )
+ if error:
+ return JsonResponse({'error': error}, status=400)
+
+ # Remove reference from old parent.
+ source_parent.children.remove(source_item.location)
+ store.update_item(source_parent, user.id)
+
+ # When target_index is provided, insert xblock at target_index position, otherwise insert at the end.
+ insert_at = target_index if target_index is not None else len(target_parent.children)
+
+ # Add to new parent at particular location.
+ target_parent.children.insert(insert_at, source_item.location)
+ store.update_item(target_parent, user.id)
+
+ log.info(
+ 'MOVE: %s moved from %s to %s at %d index',
+ unicode(source_usage_key),
+ unicode(source_parent.location),
+ unicode(target_parent_usage_key),
+ insert_at
+ )
+
+ context = {
+ 'move_source_locator': unicode(source_usage_key),
+ 'parent_locator': unicode(target_parent_usage_key),
+ 'source_index': target_index if target_index is not None else source_index
+ }
+ return JsonResponse(context)
+
+
def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_name=None, is_child=False):
"""
Duplicate an existing xblock as a child of the supplied parent_usage_key.
@@ -887,7 +1040,7 @@ def _get_gating_info(course, xblock):
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False,
course_outline=False, include_children_predicate=NEVER, parent_xblock=None, graders=None,
- user=None, course=None):
+ user=None, course=None, is_concise=False):
"""
Creates the information needed for client-side XBlockInfo.
@@ -897,6 +1050,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
There are three optional boolean parameters:
include_ancestor_info - if true, ancestor info is added to the response
include_child_info - if true, direct child info is included in the response
+ is_concise - if true, returns the concise version of xblock info, default is false.
course_outline - if true, the xblock is being rendered on behalf of the course outline.
There are certain expensive computations that do not need to be included in this case.
@@ -933,20 +1087,22 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
graders,
include_children_predicate=include_children_predicate,
user=user,
- course=course
+ course=course,
+ is_concise=is_concise
)
else:
child_info = None
release_date = _get_release_date(xblock, user)
- if xblock.category != 'course':
+ if xblock.category != 'course' and not is_concise:
visibility_state = _compute_visibility_state(
xblock, child_info, is_xblock_unit and has_changes, is_self_paced(course)
)
else:
visibility_state = None
published = modulestore().has_published_version(xblock) if not is_library_block else None
+ published_on = get_default_time_display(xblock.published_on) if published and xblock.published_on else None
# defining the default value 'True' for delete, duplicate, drag and add new child actions
# in xblock_actions for each xblock.
@@ -969,84 +1125,95 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
# a percent value out of 100, e.g. "58%" means "58/100".
pct_sign=_('%'))
+ user_partitions = get_user_partition_info(xblock, course=course)
xblock_info = {
- "id": unicode(xblock.location),
- "display_name": xblock.display_name_with_default,
- "category": xblock.category,
- "edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None,
- "published": published,
- "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,
- "visibility_state": visibility_state,
- "has_explicit_staff_lock": xblock.fields['visible_to_staff_only'].is_set_on(xblock),
- "self_paced": is_self_paced(course),
- "start": xblock.fields['start'].to_json(xblock.start),
- "graded": xblock.graded,
- "due_date": get_default_time_display(xblock.due),
- "due": xblock.fields['due'].to_json(xblock.due),
- "format": xblock.format,
- "course_graders": [grader.get('type') for grader in graders],
- "has_changes": has_changes,
- "actions": xblock_actions,
- "explanatory_message": explanatory_message,
- "group_access": xblock.group_access,
- "user_partitions": get_user_partition_info(xblock, course=course),
+ 'id': unicode(xblock.location),
+ 'display_name': xblock.display_name_with_default,
+ 'category': xblock.category,
+ 'has_children': xblock.has_children
}
-
- if xblock.category == 'sequential':
+ if is_concise:
+ if child_info and len(child_info.get('children', [])) > 0:
+ xblock_info['child_info'] = child_info
+ # Groups are labelled with their internal ids, rather than with the group name. Replace id with display name.
+ group_display_name = get_group_display_name(user_partitions, xblock_info['display_name'])
+ xblock_info['display_name'] = group_display_name if group_display_name else xblock_info['display_name']
+ else:
xblock_info.update({
- "hide_after_due": xblock.hide_after_due,
+ 'edited_on': get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None,
+ 'published': published,
+ 'published_on': published_on,
+ 'studio_url': xblock_studio_url(xblock, parent_xblock),
+ 'released_to_students': datetime.now(UTC) > xblock.start,
+ 'release_date': release_date,
+ '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),
+ 'graded': xblock.graded,
+ 'due_date': get_default_time_display(xblock.due),
+ 'due': xblock.fields['due'].to_json(xblock.due),
+ 'format': xblock.format,
+ 'course_graders': [grader.get('type') for grader in graders],
+ 'has_changes': has_changes,
+ 'actions': xblock_actions,
+ 'explanatory_message': explanatory_message,
+ 'group_access': xblock.group_access,
+ 'user_partitions': user_partitions,
})
- # update xblock_info with special exam information if the feature flag is enabled
- if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
- if xblock.category == 'course':
- xblock_info.update({
- "enable_proctored_exams": xblock.enable_proctored_exams,
- "create_zendesk_tickets": xblock.create_zendesk_tickets,
- "enable_timed_exams": xblock.enable_timed_exams
- })
- elif xblock.category == 'sequential':
+ if xblock.category == 'sequential':
xblock_info.update({
- "is_proctored_exam": xblock.is_proctored_exam,
- "is_practice_exam": xblock.is_practice_exam,
- "is_time_limited": xblock.is_time_limited,
- "exam_review_rules": xblock.exam_review_rules,
- "default_time_limit_minutes": xblock.default_time_limit_minutes,
+ 'hide_after_due': xblock.hide_after_due,
})
- # Update with gating info
- xblock_info.update(_get_gating_info(course, xblock))
-
- if xblock.category == 'sequential':
- # Entrance exam subsection should be hidden. in_entrance_exam is
- # inherited metadata, all children will have it.
- if getattr(xblock, "in_entrance_exam", False):
- xblock_info["is_header_visible"] = False
-
- if data is not None:
- xblock_info["data"] = data
- if metadata is not None:
- xblock_info["metadata"] = metadata
- if include_ancestor_info:
- xblock_info['ancestor_info'] = _create_xblock_ancestor_info(xblock, course_outline)
- if child_info:
- xblock_info['child_info'] = child_info
- if visibility_state == VisibilityState.staff_only:
- xblock_info["ancestor_has_staff_lock"] = ancestor_has_staff_lock(xblock, parent_xblock)
- else:
- xblock_info["ancestor_has_staff_lock"] = False
+ # update xblock_info with special exam information if the feature flag is enabled
+ if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
+ if xblock.category == 'course':
+ xblock_info.update({
+ 'enable_proctored_exams': xblock.enable_proctored_exams,
+ 'create_zendesk_tickets': xblock.create_zendesk_tickets,
+ 'enable_timed_exams': xblock.enable_timed_exams
+ })
+ elif xblock.category == 'sequential':
+ xblock_info.update({
+ 'is_proctored_exam': xblock.is_proctored_exam,
+ 'is_practice_exam': xblock.is_practice_exam,
+ 'is_time_limited': xblock.is_time_limited,
+ 'exam_review_rules': xblock.exam_review_rules,
+ 'default_time_limit_minutes': xblock.default_time_limit_minutes,
+ })
- if course_outline:
- if xblock_info["has_explicit_staff_lock"]:
- xblock_info["staff_only_message"] = True
- elif child_info and child_info["children"]:
- xblock_info["staff_only_message"] = all([child["staff_only_message"] for child in child_info["children"]])
+ # Update with gating info
+ xblock_info.update(_get_gating_info(course, xblock))
+
+ if xblock.category == 'sequential':
+ # Entrance exam subsection should be hidden. in_entrance_exam is
+ # inherited metadata, all children will have it.
+ if getattr(xblock, 'in_entrance_exam', False):
+ xblock_info['is_header_visible'] = False
+
+ if data is not None:
+ xblock_info['data'] = data
+ if metadata is not None:
+ xblock_info['metadata'] = metadata
+ if include_ancestor_info:
+ xblock_info['ancestor_info'] = _create_xblock_ancestor_info(xblock, course_outline, include_child_info=True)
+ if child_info:
+ xblock_info['child_info'] = child_info
+ if visibility_state == VisibilityState.staff_only:
+ xblock_info['ancestor_has_staff_lock'] = ancestor_has_staff_lock(xblock, parent_xblock)
else:
- xblock_info["staff_only_message"] = False
-
+ xblock_info['ancestor_has_staff_lock'] = False
+
+ if course_outline:
+ if xblock_info['has_explicit_staff_lock']:
+ xblock_info['staff_only_message'] = True
+ elif child_info and child_info['children']:
+ xblock_info['staff_only_message'] = all(
+ [child['staff_only_message'] for child in child_info['children']]
+ )
+ else:
+ xblock_info['staff_only_message'] = False
return xblock_info
@@ -1156,14 +1323,14 @@ def _compute_visibility_state(xblock, child_info, is_unit_with_changes, is_cours
return VisibilityState.ready
-def _create_xblock_ancestor_info(xblock, course_outline):
+def _create_xblock_ancestor_info(xblock, course_outline=False, include_child_info=False, is_concise=False):
"""
Returns information about the ancestors of an xblock. Note that the direct parent will also return
information about all of its children.
"""
ancestors = []
- def collect_ancestor_info(ancestor, include_child_info=False):
+ def collect_ancestor_info(ancestor, include_child_info=False, is_concise=False):
"""
Collect xblock info regarding the specified xblock and its ancestors.
"""
@@ -1173,16 +1340,18 @@ def collect_ancestor_info(ancestor, include_child_info=False):
ancestor,
include_child_info=include_child_info,
course_outline=course_outline,
- include_children_predicate=direct_children_only
+ include_children_predicate=direct_children_only,
+ is_concise=is_concise
))
- collect_ancestor_info(get_parent_xblock(ancestor))
- collect_ancestor_info(get_parent_xblock(xblock), include_child_info=True)
+ collect_ancestor_info(get_parent_xblock(ancestor), is_concise=is_concise)
+ collect_ancestor_info(get_parent_xblock(xblock), include_child_info=include_child_info, is_concise=is_concise)
return {
'ancestors': ancestors
}
-def _create_xblock_child_info(xblock, course_outline, graders, include_children_predicate=NEVER, user=None, course=None): # pylint: disable=line-too-long
+def _create_xblock_child_info(xblock, course_outline, graders, include_children_predicate=NEVER, user=None,
+ course=None, is_concise=False): # pylint: disable=line-too-long
"""
Returns information about the children of an xblock, as well as about the primary category
of xblock expected as children.
@@ -1203,6 +1372,7 @@ def _create_xblock_child_info(xblock, course_outline, graders, include_children_
graders=graders,
user=user,
course=course,
+ is_concise=is_concise
) for child in xblock.get_children()
]
return child_info
diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py
index 7c146e099ef9..2050dafaa197 100644
--- a/cms/djangoapps/contentstore/views/preview.py
+++ b/cms/djangoapps/contentstore/views/preview.py
@@ -274,6 +274,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
'can_edit': context.get('can_edit', True),
'can_edit_visibility': context.get('can_edit_visibility', True),
'can_add': context.get('can_add', True),
+ 'can_move': context.get('can_move', True)
}
html = render_to_string('studio_xblock_wrapper.html', template_context)
frag = wrap_fragment(frag, html)
diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py
index b22e06327fe6..2b8caff47517 100644
--- a/cms/djangoapps/contentstore/views/tests/test_container_page.py
+++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py
@@ -12,11 +12,13 @@
import contentstore.views.component as views
from contentstore.views.tests.utils import StudioPageTestCase
+from contentstore.tests.test_libraries import LibraryTestCase
+from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.factories import ItemFactory
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
-class ContainerPageTestCase(StudioPageTestCase):
+class ContainerPageTestCase(StudioPageTestCase, LibraryTestCase):
"""
Unit tests for the container page.
"""
@@ -128,6 +130,44 @@ def test_public_container_preview_html(self):
self.validate_preview_html(published_child_container, self.container_view)
self.validate_preview_html(published_child_vertical, self.reorderable_child_view)
+ def test_library_page_preview_html(self):
+ """
+ Verify that a library xblock's container (library page) preview returns the expected HTML.
+ """
+ # Add some content to library.
+ self._add_simple_content_block()
+ self.validate_preview_html(self.library, self.container_view, can_reorder=False, can_move=False)
+
+ def test_library_content_preview_html(self):
+ """
+ Verify that a library content block container page preview returns the expected HTML.
+ """
+ # Library content block is only supported in split courses.
+ with modulestore().default_store(ModuleStoreEnum.Type.split):
+ course = CourseFactory.create()
+
+ # Add some content to library
+ self._add_simple_content_block()
+
+ # Create a library content block
+ lc_block = self._add_library_content_block(course, self.lib_key)
+ self.assertEqual(len(lc_block.children), 0)
+
+ # Refresh children to be reflected in lc_block
+ lc_block = self._refresh_children(lc_block)
+ self.assertEqual(len(lc_block.children), 1)
+
+ self.validate_preview_html(
+ lc_block,
+ self.container_view,
+ can_add=False,
+ can_reorder=False,
+ can_move=False,
+ can_edit=True,
+ can_duplicate=False,
+ can_delete=False
+ )
+
def test_draft_container_preview_html(self):
"""
Verify that a draft xblock's container preview returns the expected HTML.
diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py
index c38daa60200c..37c9792b3fc5 100644
--- a/cms/djangoapps/contentstore/views/tests/test_course_index.py
+++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py
@@ -352,11 +352,16 @@ def setUp(self):
parent_location=self.vertical.location, category="video", display_name="My Video"
)
- def test_json_responses(self):
+ @ddt.data(True, False)
+ def test_json_responses(self, is_concise):
"""
Verify the JSON responses returned for the course.
+
+ Arguments:
+ is_concise (Boolean) : If True, fetch concise version of course outline.
"""
outline_url = reverse_course_url('course_handler', self.course.id)
+ outline_url = outline_url + '?format=concise' if is_concise else outline_url
resp = self.client.get(outline_url, HTTP_ACCEPT='application/json')
json_response = json.loads(resp.content)
@@ -364,8 +369,8 @@ def test_json_responses(self):
self.assertEqual(json_response['category'], 'course')
self.assertEqual(json_response['id'], unicode(self.course.location))
self.assertEqual(json_response['display_name'], self.course.display_name)
- self.assertTrue(json_response['published'])
- self.assertIsNone(json_response['visibility_state'])
+ self.assertNotEqual(json_response.get('published', False), is_concise)
+ self.assertIsNone(json_response.get('visibility_state'))
# Now verify the first child
children = json_response['child_info']['children']
@@ -374,24 +379,25 @@ def test_json_responses(self):
self.assertEqual(first_child_response['category'], 'chapter')
self.assertEqual(first_child_response['id'], unicode(self.chapter.location))
self.assertEqual(first_child_response['display_name'], 'Week 1')
- self.assertTrue(json_response['published'])
- self.assertEqual(first_child_response['visibility_state'], VisibilityState.unscheduled)
+ self.assertNotEqual(json_response.get('published', False), is_concise)
+ if not is_concise:
+ self.assertEqual(first_child_response['visibility_state'], VisibilityState.unscheduled)
self.assertGreater(len(first_child_response['child_info']['children']), 0)
# Finally, validate the entire response for consistency
- self.assert_correct_json_response(json_response)
+ self.assert_correct_json_response(json_response, is_concise)
- def assert_correct_json_response(self, json_response):
+ def assert_correct_json_response(self, json_response, is_concise=False):
"""
Asserts that the JSON response is syntactically consistent
"""
self.assertIsNotNone(json_response['display_name'])
self.assertIsNotNone(json_response['id'])
self.assertIsNotNone(json_response['category'])
- self.assertTrue(json_response['published'])
+ self.assertNotEqual(json_response.get('published', False), is_concise)
if json_response.get('child_info', None):
for child_response in json_response['child_info']['children']:
- self.assert_correct_json_response(child_response)
+ self.assert_correct_json_response(child_response, is_concise)
def test_course_outline_initial_state(self):
course_module = modulestore().get_item(self.course.location)
diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py
index 93637793b985..3e28f0e20595 100644
--- a/cms/djangoapps/contentstore/views/tests/test_item.py
+++ b/cms/djangoapps/contentstore/views/tests/test_item.py
@@ -14,13 +14,15 @@
from django.core.urlresolvers import reverse
from contentstore.utils import reverse_usage_url, reverse_course_url
+from opaque_keys import InvalidKeyError
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
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, add_container_page_publishing_info
+ create_xblock_info, _get_source_index, _get_module_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
@@ -384,6 +386,59 @@ def test_get_user_partitions_and_groups(self):
])
self.assertEqual(result["group_access"], {})
+ @ddt.data('ancestorInfo', '')
+ def test_ancestor_info(self, field_type):
+ """
+ Test that we get correct ancestor info.
+
+ Arguments:
+ field_type (string): If field_type=ancestorInfo, fetch ancestor info of the XBlock otherwise not.
+ """
+
+ # Create a parent chapter
+ chap1 = self.create_xblock(parent_usage_key=self.course.location, display_name='chapter1', category='chapter')
+ chapter_usage_key = self.response_usage_key(chap1)
+
+ # create a sequential
+ seq1 = self.create_xblock(parent_usage_key=chapter_usage_key, display_name='seq1', category='sequential')
+ seq_usage_key = self.response_usage_key(seq1)
+
+ # create a vertical
+ vert1 = self.create_xblock(parent_usage_key=seq_usage_key, display_name='vertical1', category='vertical')
+ vert_usage_key = self.response_usage_key(vert1)
+
+ # create problem and an html component
+ problem1 = self.create_xblock(parent_usage_key=vert_usage_key, display_name='problem1', category='problem')
+ problem_usage_key = self.response_usage_key(problem1)
+
+ def assert_xblock_info(xblock, xblock_info):
+ """
+ Assert we have correct xblock info.
+
+ Arguments:
+ xblock (XBlock): An XBlock item.
+ xblock_info (dict): A dict containing xblock information.
+ """
+ self.assertEqual(unicode(xblock.location), xblock_info['id'])
+ self.assertEqual(xblock.display_name, xblock_info['display_name'])
+ self.assertEqual(xblock.category, xblock_info['category'])
+
+ for usage_key in (problem_usage_key, vert_usage_key, seq_usage_key, chapter_usage_key):
+ xblock = self.get_item_from_modulestore(usage_key)
+ url = reverse_usage_url('xblock_handler', usage_key) + '?fields={field_type}'.format(field_type=field_type)
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ response = json.loads(response.content)
+ if field_type == 'ancestorInfo':
+ self.assertIn('ancestors', response)
+ for ancestor_info in response['ancestors']:
+ parent_xblock = xblock.get_parent()
+ assert_xblock_info(parent_xblock, ancestor_info)
+ xblock = parent_xblock
+ else:
+ self.assertNotIn('ancestors', response)
+ self.assertEqual(_get_module_info(xblock), response)
+
@ddt.ddt
class DeleteItem(ItemTest):
@@ -680,6 +735,416 @@ def verify_name(source_usage_key, parent_usage_key, expected_name, display_name=
verify_name(self.seq_usage_key, self.chapter_usage_key, "customized name", display_name="customized name")
+@ddt.ddt
+class TestMoveItem(ItemTest):
+ """
+ Tests for move item.
+ """
+ def setUp(self):
+ """
+ Creates the test course structure to build course outline tree.
+ """
+ super(TestMoveItem, self).setUp()
+ self.setup_course()
+
+ def setup_course(self, default_store=None):
+ """
+ Helper method to create the course.
+ """
+ if not default_store:
+ default_store = self.store.default_modulestore.get_modulestore_type()
+
+ self.course = CourseFactory.create(default_store=default_store)
+
+ # Create group configurations
+ self.course.user_partitions = [
+ UserPartition(0, 'first_partition', 'Test Partition', [Group("0", 'alpha'), Group("1", 'beta')])
+ ]
+ self.store.update_item(self.course, self.user.id)
+
+ # Create a parent chapter
+ chap1 = self.create_xblock(parent_usage_key=self.course.location, display_name='chapter1', category='chapter')
+ self.chapter_usage_key = self.response_usage_key(chap1)
+
+ chap2 = self.create_xblock(parent_usage_key=self.course.location, display_name='chapter2', category='chapter')
+ self.chapter2_usage_key = self.response_usage_key(chap2)
+
+ # Create a sequential
+ seq1 = self.create_xblock(parent_usage_key=self.chapter_usage_key, display_name='seq1', category='sequential')
+ self.seq_usage_key = self.response_usage_key(seq1)
+
+ seq2 = self.create_xblock(parent_usage_key=self.chapter_usage_key, display_name='seq2', category='sequential')
+ self.seq2_usage_key = self.response_usage_key(seq2)
+
+ # Create a vertical
+ vert1 = self.create_xblock(parent_usage_key=self.seq_usage_key, display_name='vertical1', category='vertical')
+ self.vert_usage_key = self.response_usage_key(vert1)
+
+ vert2 = self.create_xblock(parent_usage_key=self.seq_usage_key, display_name='vertical2', category='vertical')
+ self.vert2_usage_key = self.response_usage_key(vert2)
+
+ # Create problem and an html component
+ problem1 = self.create_xblock(parent_usage_key=self.vert_usage_key, display_name='problem1', category='problem')
+ self.problem_usage_key = self.response_usage_key(problem1)
+
+ html1 = self.create_xblock(parent_usage_key=self.vert_usage_key, display_name='html1', category='html')
+ self.html_usage_key = self.response_usage_key(html1)
+
+ # Create a content experiment
+ resp = self.create_xblock(category='split_test', parent_usage_key=self.vert_usage_key)
+ self.split_test_usage_key = self.response_usage_key(resp)
+
+ def setup_and_verify_content_experiment(self, partition_id):
+ """
+ Helper method to set up group configurations to content experiment.
+
+ Arguments:
+ partition_id (int): User partition id.
+ """
+ split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True)
+
+ # Initially, no user_partition_id is set, and the split_test has no children.
+ self.assertEqual(split_test.user_partition_id, -1)
+ self.assertEqual(len(split_test.children), 0)
+
+ # Set group configuration
+ self.client.ajax_post(
+ reverse_usage_url("xblock_handler", self.split_test_usage_key),
+ data={'metadata': {'user_partition_id': str(partition_id)}}
+ )
+ split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True)
+ self.assertEqual(split_test.user_partition_id, partition_id)
+ self.assertEqual(len(split_test.children), len(self.course.user_partitions[partition_id].groups))
+ return split_test
+
+ def _move_component(self, source_usage_key, target_usage_key, target_index=None):
+ """
+ Helper method to send move request and returns the response.
+
+ Arguments:
+ source_usage_key (BlockUsageLocator): Locator of source item.
+ target_usage_key (BlockUsageLocator): Locator of target parent.
+ target_index (int): If provided, insert source item at the provided index location in target_usage_key item.
+
+ Returns:
+ resp (JsonResponse): Response after the move operation is complete.
+ """
+ data = {
+ 'move_source_locator': unicode(source_usage_key),
+ 'parent_locator': unicode(target_usage_key)
+ }
+ if target_index is not None:
+ data['target_index'] = target_index
+
+ return self.client.patch(
+ reverse('contentstore.views.xblock_handler'),
+ json.dumps(data),
+ content_type='application/json'
+ )
+
+ def assert_move_item(self, source_usage_key, target_usage_key, target_index=None):
+ """
+ Assert move component.
+
+ Arguments:
+ source_usage_key (BlockUsageLocator): Locator of source item.
+ target_usage_key (BlockUsageLocator): Locator of target parent.
+ target_index (int): If provided, insert source item at the provided index location in target_usage_key item.
+ """
+ parent_loc = self.store.get_parent_location(source_usage_key)
+ parent = self.get_item_from_modulestore(parent_loc)
+ source_index = _get_source_index(source_usage_key, parent)
+ expected_index = target_index if target_index is not None else source_index
+ response = self._move_component(source_usage_key, target_usage_key, target_index)
+ self.assertEqual(response.status_code, 200)
+ response = json.loads(response.content)
+ self.assertEqual(response['move_source_locator'], unicode(source_usage_key))
+ self.assertEqual(response['parent_locator'], unicode(target_usage_key))
+ self.assertEqual(response['source_index'], expected_index)
+ new_parent_loc = self.store.get_parent_location(source_usage_key)
+ self.assertEqual(new_parent_loc, target_usage_key)
+ self.assertNotEqual(parent_loc, new_parent_loc)
+
+ @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
+ def test_move_component(self, store_type):
+ """
+ Test move component with different xblock types.
+
+ Arguments:
+ store_type (ModuleStoreEnum.Type): Type of modulestore to create test course in.
+ """
+ self.setup_course(default_store=store_type)
+ for source_usage_key, target_usage_key in [
+ (self.html_usage_key, self.vert2_usage_key),
+ (self.vert_usage_key, self.seq2_usage_key),
+ (self.seq_usage_key, self.chapter2_usage_key)
+ ]:
+ self.assert_move_item(source_usage_key, target_usage_key)
+
+ def test_move_source_index(self):
+ """
+ Test moving an item to a particular index.
+ """
+ parent = self.get_item_from_modulestore(self.vert_usage_key)
+ children = parent.get_children()
+ self.assertEqual(len(children), 3)
+
+ # Create a component within vert2.
+ resp = self.create_xblock(parent_usage_key=self.vert2_usage_key, display_name='html2', category='html')
+ html2_usage_key = self.response_usage_key(resp)
+
+ # Move html2_usage_key inside vert_usage_key at second position.
+ self.assert_move_item(html2_usage_key, self.vert_usage_key, 1)
+ parent = self.get_item_from_modulestore(self.vert_usage_key)
+ children = parent.get_children()
+ self.assertEqual(len(children), 4)
+ self.assertEqual(children[1].location, html2_usage_key)
+
+ def test_move_undo(self):
+ """
+ Test move a component and move it back (undo).
+ """
+ # Get the initial index of the component
+ parent = self.get_item_from_modulestore(self.vert_usage_key)
+ original_index = _get_source_index(self.html_usage_key, parent)
+
+ # Move component and verify that response contains initial index
+ response = self._move_component(self.html_usage_key, self.vert2_usage_key)
+ response = json.loads(response.content)
+ self.assertEquals(original_index, response['source_index'])
+
+ # Verify that new parent has the moved component at the last index.
+ parent = self.get_item_from_modulestore(self.vert2_usage_key)
+ self.assertEqual(self.html_usage_key, parent.children[-1])
+
+ # Verify original and new index is different now.
+ source_index = _get_source_index(self.html_usage_key, parent)
+ self.assertNotEquals(original_index, source_index)
+
+ # Undo Move to the original index, use the source index fetched from the response.
+ response = self._move_component(self.html_usage_key, self.vert_usage_key, response['source_index'])
+ response = json.loads(response.content)
+ self.assertEquals(original_index, response['source_index'])
+
+ def test_move_large_target_index(self):
+ """
+ Test moving an item at a large index would generate an error message.
+ """
+ parent = self.get_item_from_modulestore(self.vert2_usage_key)
+ parent_children_length = len(parent.children)
+ response = self._move_component(self.html_usage_key, self.vert2_usage_key, parent_children_length + 10)
+ self.assertEqual(response.status_code, 400)
+ response = json.loads(response.content)
+
+ expected_error = 'You can not move {usage_key} at an invalid index ({target_index}).'.format(
+ usage_key=self.html_usage_key,
+ target_index=parent_children_length + 10
+ )
+ self.assertEqual(expected_error, response['error'])
+ new_parent_loc = self.store.get_parent_location(self.html_usage_key)
+ self.assertEqual(new_parent_loc, self.vert_usage_key)
+
+ def test_invalid_move(self):
+ """
+ Test invalid move.
+ """
+ parent_loc = self.store.get_parent_location(self.html_usage_key)
+ response = self._move_component(self.html_usage_key, self.seq_usage_key)
+ self.assertEqual(response.status_code, 400)
+ response = json.loads(response.content)
+
+ expected_error = 'You can not move {source_type} into {target_type}.'.format(
+ source_type=self.html_usage_key.block_type,
+ target_type=self.seq_usage_key.block_type
+ )
+ self.assertEqual(expected_error, response['error'])
+ new_parent_loc = self.store.get_parent_location(self.html_usage_key)
+ self.assertEqual(new_parent_loc, parent_loc)
+
+ def test_move_current_parent(self):
+ """
+ Test that a component can not be moved to it's current parent.
+ """
+ parent_loc = self.store.get_parent_location(self.html_usage_key)
+ self.assertEqual(parent_loc, self.vert_usage_key)
+ response = self._move_component(self.html_usage_key, self.vert_usage_key)
+ self.assertEqual(response.status_code, 400)
+ response = json.loads(response.content)
+
+ self.assertEqual(response['error'], 'You can not move an item into the same parent.')
+ self.assertEqual(self.store.get_parent_location(self.html_usage_key), parent_loc)
+
+ def test_can_not_move_into_itself(self):
+ """
+ Test that a component can not be moved to itself.
+ """
+ library_content = self.create_xblock(
+ parent_usage_key=self.vert_usage_key, display_name='library content block', category='library_content'
+ )
+ library_content_usage_key = self.response_usage_key(library_content)
+ parent_loc = self.store.get_parent_location(library_content_usage_key)
+ self.assertEqual(parent_loc, self.vert_usage_key)
+ response = self._move_component(library_content_usage_key, library_content_usage_key)
+ self.assertEqual(response.status_code, 400)
+ response = json.loads(response.content)
+
+ self.assertEqual(response['error'], 'You can not move an item into itself.')
+ self.assertEqual(self.store.get_parent_location(self.html_usage_key), parent_loc)
+
+ def test_move_library_content(self):
+ """
+ Test that library content can be moved to any other valid location.
+ """
+ library_content = self.create_xblock(
+ parent_usage_key=self.vert_usage_key, display_name='library content block', category='library_content'
+ )
+ library_content_usage_key = self.response_usage_key(library_content)
+ parent_loc = self.store.get_parent_location(library_content_usage_key)
+ self.assertEqual(parent_loc, self.vert_usage_key)
+ self.assert_move_item(library_content_usage_key, self.vert2_usage_key)
+
+ def test_move_into_library_content(self):
+ """
+ Test that a component can be moved into library content.
+ """
+ library_content = self.create_xblock(
+ parent_usage_key=self.vert_usage_key, display_name='library content block', category='library_content'
+ )
+ library_content_usage_key = self.response_usage_key(library_content)
+ self.assert_move_item(self.html_usage_key, library_content_usage_key)
+
+ def test_move_content_experiment(self):
+ """
+ Test that a content experiment can be moved.
+ """
+ self.setup_and_verify_content_experiment(0)
+
+ # Move content experiment
+ self.assert_move_item(self.split_test_usage_key, self.vert2_usage_key)
+
+ def test_move_content_experiment_components(self):
+ """
+ Test that component inside content experiment can be moved to any other valid location.
+ """
+ split_test = self.setup_and_verify_content_experiment(0)
+
+ # Add html component to Group A.
+ html1 = self.create_xblock(
+ parent_usage_key=split_test.children[0], display_name='html1', category='html'
+ )
+ html_usage_key = self.response_usage_key(html1)
+
+ # Move content experiment
+ self.assert_move_item(html_usage_key, self.vert2_usage_key)
+
+ def test_move_into_content_experiment_groups(self):
+ """
+ Test that a component can be moved to content experiment groups.
+ """
+ split_test = self.setup_and_verify_content_experiment(0)
+ self.assert_move_item(self.html_usage_key, split_test.children[0])
+
+ def test_can_not_move_into_content_experiment_level(self):
+ """
+ Test that a component can not be moved directly to content experiment level.
+ """
+ self.setup_and_verify_content_experiment(0)
+ response = self._move_component(self.html_usage_key, self.split_test_usage_key)
+ self.assertEqual(response.status_code, 400)
+ response = json.loads(response.content)
+
+ self.assertEqual(response['error'], 'You can not move an item directly into content experiment.')
+ self.assertEqual(self.store.get_parent_location(self.html_usage_key), self.vert_usage_key)
+
+ def test_can_not_move_content_experiment_into_its_children(self):
+ """
+ Test that a content experiment can not be moved inside any of it's children.
+ """
+ split_test = self.setup_and_verify_content_experiment(0)
+
+ # Try to move content experiment inside it's child groups.
+ for child_vert_usage_key in split_test.children:
+ response = self._move_component(self.split_test_usage_key, child_vert_usage_key)
+ self.assertEqual(response.status_code, 400)
+ response = json.loads(response.content)
+
+ self.assertEqual(response['error'], 'You can not move an item into it\'s child.')
+ self.assertEqual(self.store.get_parent_location(self.split_test_usage_key), self.vert_usage_key)
+
+ # Create content experiment inside group A and set it's group configuration.
+ resp = self.create_xblock(category='split_test', parent_usage_key=split_test.children[0])
+ child_split_test_usage_key = self.response_usage_key(resp)
+ self.client.ajax_post(
+ reverse_usage_url("xblock_handler", child_split_test_usage_key),
+ data={'metadata': {'user_partition_id': str(0)}}
+ )
+ child_split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True)
+
+ # Try to move content experiment further down the level to a child group A nested inside main group A.
+ response = self._move_component(self.split_test_usage_key, child_split_test.children[0])
+ self.assertEqual(response.status_code, 400)
+ response = json.loads(response.content)
+
+ self.assertEqual(response['error'], 'You can not move an item into it\'s child.')
+ self.assertEqual(self.store.get_parent_location(self.split_test_usage_key), self.vert_usage_key)
+
+ def test_move_invalid_source_index(self):
+ """
+ Test moving an item to an invalid index.
+ """
+ target_index = 'test_index'
+ parent_loc = self.store.get_parent_location(self.html_usage_key)
+ response = self._move_component(self.html_usage_key, self.vert2_usage_key, target_index)
+ self.assertEqual(response.status_code, 400)
+ response = json.loads(response.content)
+
+ error = 'You must provide target_index ({target_index}) as an integer.'.format(target_index=target_index)
+ self.assertEqual(response['error'], error)
+ new_parent_loc = self.store.get_parent_location(self.html_usage_key)
+ self.assertEqual(new_parent_loc, parent_loc)
+
+ def test_move_no_target_locator(self):
+ """
+ Test move an item without specifying the target location.
+ """
+ data = {'move_source_locator': unicode(self.html_usage_key)}
+ with self.assertRaises(InvalidKeyError):
+ self.client.patch(
+ reverse('contentstore.views.xblock_handler'),
+ json.dumps(data),
+ content_type='application/json'
+ )
+
+ def test_no_move_source_locator(self):
+ """
+ Test patch request without providing a move source locator.
+ """
+ response = self.client.patch(
+ reverse('contentstore.views.xblock_handler')
+ )
+ self.assertEqual(response.status_code, 400)
+ response = json.loads(response.content)
+ self.assertEqual(response['error'], 'Patch request did not recognise any parameters to handle.')
+
+ @patch('contentstore.views.item.log')
+ def test_move_logging(self, mock_logger):
+ """
+ Test logging when an item is successfully moved.
+
+ Arguments:
+ mock_logger (object): A mock logger object.
+ """
+ insert_at = 0
+ self.assert_move_item(self.html_usage_key, self.vert2_usage_key, insert_at)
+ mock_logger.info.assert_called_with(
+ 'MOVE: %s moved from %s to %s at %d index',
+ unicode(self.html_usage_key),
+ unicode(self.vert_usage_key),
+ unicode(self.vert2_usage_key),
+ insert_at
+ )
+
+
class TestDuplicateItemWithAsides(ItemTest, DuplicateHelper):
"""
Test the duplicate method for blocks with asides.
@@ -1312,6 +1777,31 @@ def test_create_groups(self):
self.assertEqual(vertical_0.location, split_test.group_id_to_child['0'])
self.assertEqual(vertical_1.location, split_test.group_id_to_child['1'])
+ def test_split_xblock_info_group_name(self):
+ """
+ Test that concise outline for split test component gives display name as group name.
+ """
+ split_test = self.get_item_from_modulestore(self.split_test_usage_key, verify_is_draft=True)
+ # Initially, no user_partition_id is set, and the split_test has no children.
+ self.assertEqual(split_test.user_partition_id, -1)
+ self.assertEqual(len(split_test.children), 0)
+ # Set the user_partition_id to 0.
+ split_test = self._update_partition_id(0)
+ # Verify that child verticals have been set to match the groups
+ self.assertEqual(len(split_test.children), 2)
+
+ # Get xblock outline
+ xblock_info = create_xblock_info(
+ split_test,
+ is_concise=True,
+ include_child_info=True,
+ include_children_predicate=lambda xblock: xblock.has_children,
+ course=self.course,
+ user=self.request.user
+ )
+ self.assertEqual(xblock_info['child_info']['children'][0]['display_name'], 'alpha')
+ self.assertEqual(xblock_info['child_info']['children'][1]['display_name'], 'beta')
+
def test_change_user_partition_id(self):
"""
Test what happens when the user_partition_id is changed to a different groups
diff --git a/cms/djangoapps/contentstore/views/tests/utils.py b/cms/djangoapps/contentstore/views/tests/utils.py
index 094a789214be..6c2580fd1a4c 100644
--- a/cms/djangoapps/contentstore/views/tests/utils.py
+++ b/cms/djangoapps/contentstore/views/tests/utils.py
@@ -41,33 +41,48 @@ def get_preview_html(self, xblock, view_name):
resp_content = json.loads(resp.content)
return resp_content['html']
- def validate_preview_html(self, xblock, view_name, can_add=True):
+ def validate_preview_html(self, xblock, view_name, can_add=True, can_reorder=True, can_move=True,
+ can_edit=True, can_duplicate=True, can_delete=True):
"""
Verify that the specified xblock's preview has the expected HTML elements.
"""
html = self.get_preview_html(xblock, view_name)
- self.validate_html_for_add_buttons(html, can_add)
+ self.validate_html_for_action_button(
+ html,
+ '
',
+ can_add
+ )
+ self.validate_html_for_action_button(
+ html,
+ ' ',
+ can_reorder
+ )
+ self.validate_html_for_action_button(
+ html,
+ '',
+ can_move
+ )
+ self.validate_html_for_action_button(
+ html,
+ 'button class="btn-default edit-button action-button">',
+ can_edit
+ )
+ self.validate_html_for_action_button(
+ html,
+ '',
+ can_duplicate
+ )
+ self.validate_html_for_action_button(
+ html,
+ '',
+ can_delete
+ )
- # Verify drag handles always appear.
- drag_handle_html = ' '
- self.assertIn(drag_handle_html, html)
-
- # Verify that there are no action buttons for public blocks
- expected_button_html = [
- '',
- ' ',
- ' '
- ]
- for button_html in expected_button_html:
- self.assertIn(button_html, html)
-
- def validate_html_for_add_buttons(self, html, can_add=True):
+ def validate_html_for_action_button(self, html, expected_html, can_action=True):
"""
- Validate that the specified HTML has the appropriate add actions for the current publish state.
+ Validate that the specified HTML has specific action..
"""
- # Verify that there are no add buttons for public blocks
- add_button_html = '
'
- if can_add:
- self.assertIn(add_button_html, html)
+ if can_action:
+ self.assertIn(expected_html, html)
else:
- self.assertNotIn(add_button_html, html)
+ self.assertNotIn(expected_html, html)
diff --git a/cms/static/cms/js/main.js b/cms/static/cms/js/main.js
index 74e654241b2b..1e5fb698d1c0 100644
--- a/cms/static/cms/js/main.js
+++ b/cms/static/cms/js/main.js
@@ -6,7 +6,7 @@
'common/js/components/views/feedback_notification', 'coffee/src/ajax_prefix',
'jquery.cookie'],
function(domReady, $, str, Backbone, gettext, NotificationView) {
- var main;
+ var main, sendJSON;
main = function() {
AjaxPrefix.addAjaxPrefix(jQuery, function() {
return $("meta[name='path_prefix']").attr('content');
@@ -45,20 +45,26 @@
});
return msg.show();
});
- $.postJSON = function(url, data, callback) {
+ sendJSON = function(url, data, callback, type) { // eslint-disable-line no-param-reassign
if ($.isFunction(data)) {
callback = data;
data = undefined;
}
return $.ajax({
url: url,
- type: 'POST',
+ type: type,
contentType: 'application/json; charset=utf-8',
dataType: 'json',
data: JSON.stringify(data),
success: callback
});
};
+ $.postJSON = function(url, data, callback) { // eslint-disable-line no-param-reassign
+ return sendJSON(url, data, callback, 'POST');
+ };
+ $.patchJSON = function(url, data, callback) { // eslint-disable-line no-param-reassign
+ return sendJSON(url, data, callback, 'PATCH');
+ };
return domReady(function() {
if (window.onTouchBasedDevice()) {
return $('body').addClass('touch-based-device');
diff --git a/cms/static/cms/js/spec/main.js b/cms/static/cms/js/spec/main.js
index 45efca4dc2a2..393474e1f7f1 100644
--- a/cms/static/cms/js/spec/main.js
+++ b/cms/static/cms/js/spec/main.js
@@ -282,7 +282,9 @@
'js/spec/views/pages/library_users_spec',
'js/spec/views/modals/base_modal_spec',
'js/spec/views/modals/edit_xblock_spec',
+ 'js/spec/views/modals/move_xblock_modal_spec',
'js/spec/views/modals/validation_error_modal_spec',
+ 'js/spec/views/move_xblock_spec',
'js/spec/views/settings/main_spec',
'js/spec/factories/xblock_validation_spec',
'js/certificates/spec/models/certificate_spec',
diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js
index e3fd1c706cc5..fd7fdd8d9054 100644
--- a/cms/static/js/models/xblock_info.js
+++ b/cms/static/js/models/xblock_info.js
@@ -49,6 +49,10 @@ function(Backbone, _, str, ModuleUtils) {
* publishing info was explicitly requested.
*/
'published_by': null,
+ /**
+ * True if the xblock is a parentable xblock.
+ */
+ has_children: null,
/**
* True if the xblock has changes.
* Note: this is not always provided as a performance optimization. It is only provided for
diff --git a/cms/static/js/spec/views/container_spec.js b/cms/static/js/spec/views/container_spec.js
index 55161dd04fe7..cff5be452fd4 100644
--- a/cms/static/js/spec/views/container_spec.js
+++ b/cms/static/js/spec/views/container_spec.js
@@ -69,7 +69,7 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'js/spec_
// Give the leaf elements some height to mimic actual components. Otherwise
// drag and drop fails as the elements on bunched on top of each other.
- $('.level-element').css('height', 200);
+ $('.level-element').css('height', 230);
return requests;
};
@@ -92,7 +92,7 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'js/spec_
var targetElement = getComponent(targetLocator),
targetTop = targetElement.offset().top + 1,
handle = getDragHandle(sourceLocator),
- handleY = handle.offset().top + (handle.height() / 2),
+ handleY = handle.offset().top,
dy = targetTop - handleY;
handle.simulate('drag', {dy: dy});
};
diff --git a/cms/static/js/spec/views/modals/move_xblock_modal_spec.js b/cms/static/js/spec/views/modals/move_xblock_modal_spec.js
new file mode 100644
index 000000000000..f2e8f457b4ac
--- /dev/null
+++ b/cms/static/js/spec/views/modals/move_xblock_modal_spec.js
@@ -0,0 +1,80 @@
+define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
+ 'common/js/spec_helpers/template_helpers', 'common/js/spec_helpers/view_helpers',
+ 'js/views/modals/move_xblock_modal', 'js/models/xblock_info'],
+ function($, _, AjaxHelpers, TemplateHelpers, ViewHelpers, MoveXBlockModal, XBlockInfo) {
+ 'use strict';
+ describe('MoveXBlockModal', function() {
+ var modal,
+ showModal,
+ DISPLAY_NAME = 'HTML 101',
+ OUTLINE_URL = '/course/cid?format=concise',
+ ANCESTORS_URL = '/xblock/USAGE_ID?fields=ancestorInfo';
+
+ showModal = function() {
+ modal = new MoveXBlockModal({
+ sourceXBlockInfo: new XBlockInfo({
+ id: 'USAGE_ID',
+ display_name: DISPLAY_NAME,
+ category: 'html'
+ }),
+ sourceParentXBlockInfo: new XBlockInfo({
+ id: 'PARENT_ID',
+ display_name: 'VERT 101',
+ category: 'vertical'
+ }),
+ XBlockURLRoot: '/xblock',
+ outlineURL: OUTLINE_URL,
+ XBlockAncestorInfoURL: ANCESTORS_URL
+
+ });
+ modal.show();
+ };
+
+ beforeEach(function() {
+ setFixtures('
');
+ TemplateHelpers.installTemplates([
+ 'basic-modal',
+ 'modal-button',
+ 'move-xblock-modal'
+ ]);
+ });
+
+ afterEach(function() {
+ modal.hide();
+ });
+
+ it('rendered as expected', function() {
+ showModal();
+ expect(
+ modal.$el.find('.modal-header .title').contents().get(0).nodeValue.trim()
+ ).toEqual('Move: ' + DISPLAY_NAME);
+ expect(
+ modal.$el.find('.modal-sr-title').text().trim()
+ ).toEqual('Choose a location to move your component to');
+ expect(modal.$el.find('.modal-actions .action-primary.action-move').text()).toEqual('Move');
+ });
+
+ it('sends request to fetch course outline', function() {
+ var requests = AjaxHelpers.requests(this),
+ renderViewsSpy;
+ showModal();
+ expect(modal.$el.find('.ui-loading.is-hidden')).not.toExist();
+ renderViewsSpy = spyOn(modal, 'renderViews');
+ expect(requests.length).toEqual(2);
+ AjaxHelpers.expectRequest(requests, 'GET', OUTLINE_URL);
+ AjaxHelpers.respondWithJson(requests, {});
+ AjaxHelpers.expectRequest(requests, 'GET', ANCESTORS_URL);
+ AjaxHelpers.respondWithJson(requests, {});
+ expect(renderViewsSpy).toHaveBeenCalled();
+ expect(modal.$el.find('.ui-loading.is-hidden')).toExist();
+ });
+
+ it('shows error notification when fetch course outline request fails', function() {
+ var requests = AjaxHelpers.requests(this),
+ notificationSpy = ViewHelpers.createNotificationSpy('Error');
+ showModal();
+ AjaxHelpers.respondWithError(requests);
+ ViewHelpers.verifyNotificationShowing(notificationSpy, "Studio's having trouble saving your work");
+ });
+ });
+ });
diff --git a/cms/static/js/spec/views/move_xblock_spec.js b/cms/static/js/spec/views/move_xblock_spec.js
new file mode 100644
index 000000000000..3911124f0cb5
--- /dev/null
+++ b/cms/static/js/spec/views/move_xblock_spec.js
@@ -0,0 +1,766 @@
+define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'js/spec_helpers/edit_helpers',
+ 'common/js/spec_helpers/template_helpers', 'common/js/spec_helpers/view_helpers',
+ 'js/views/modals/move_xblock_modal', 'js/views/pages/container', 'edx-ui-toolkit/js/utils/html-utils',
+ 'edx-ui-toolkit/js/utils/string-utils', 'js/models/xblock_info'],
+ function($, _, AjaxHelpers, EditHelpers, TemplateHelpers, ViewHelpers, MoveXBlockModal, ContainerPage, HtmlUtils,
+ StringUtils, XBlockInfo) {
+ 'use strict';
+ describe('MoveXBlock', function() {
+ var modal, showModal, renderViews, createXBlockInfo, createCourseOutline, courseOutlineOptions,
+ parentChildMap, categoryMap, createChildXBlockInfo, xblockAncestorInfo, courseOutline,
+ verifyBreadcrumbViewInfo, verifyListViewInfo, getDisplayedInfo, clickForwardButton,
+ clickBreadcrumbButton, verifyXBlockInfo, nextCategory, verifyMoveEnabled, getSentRequests,
+ verifyNotificationStatus, sendMoveXBlockRequest, moveXBlockWithSuccess, getMovedAlertNotification,
+ verifyConfirmationFeedbackTitleText, verifyConfirmationFeedbackRedirectLinkText,
+ verifyUndoConfirmationFeedbackTitleText, verifyConfirmationFeedbackUndoMoveActionText,
+ sourceParentXBlockInfo, mockContainerPage, createContainerPage, containerPage,
+ sourceDisplayName = 'component_display_name_0',
+ sourceLocator = 'component_ID_0',
+ sourceParentLocator = 'unit_ID_0';
+
+ parentChildMap = {
+ course: 'section',
+ section: 'subsection',
+ subsection: 'unit',
+ unit: 'component'
+ };
+
+ categoryMap = {
+ section: 'chapter',
+ subsection: 'sequential',
+ unit: 'vertical',
+ component: 'component'
+ };
+
+ courseOutlineOptions = {
+ section: 2,
+ subsection: 2,
+ unit: 2,
+ component: 2
+ };
+
+ xblockAncestorInfo = {
+ ancestors: [
+ {
+ category: 'vertical',
+ display_name: 'unit_display_name_0',
+ id: 'unit_ID_0'
+ },
+ {
+ category: 'sequential',
+ display_name: 'subsection_display_name_0',
+ id: 'subsection_ID_0'
+ },
+ {
+ category: 'chapter',
+ display_name: 'section_display_name_0',
+ id: 'section_ID_0'
+ },
+ {
+ category: 'course',
+ display_name: 'Demo Course',
+ id: 'COURSE_ID_101'
+ }
+ ]
+ };
+
+ sourceParentXBlockInfo = new XBlockInfo({
+ id: sourceParentLocator,
+ display_name: 'unit_display_name_0',
+ category: 'vertical'
+ });
+
+ createContainerPage = function() {
+ containerPage = new ContainerPage({
+ model: sourceParentXBlockInfo,
+ templates: EditHelpers.mockComponentTemplates,
+ el: $('#content'),
+ isUnitPage: true
+ });
+ };
+
+ beforeEach(function() {
+ setFixtures("
");
+ mockContainerPage = readFixtures('mock/mock-container-page.underscore');
+ TemplateHelpers.installTemplates([
+ 'basic-modal',
+ 'modal-button',
+ 'move-xblock-modal'
+ ]);
+ appendSetFixtures(mockContainerPage);
+ createContainerPage();
+ courseOutline = createCourseOutline(courseOutlineOptions);
+ showModal();
+ });
+
+ afterEach(function() {
+ modal.hide();
+ courseOutline = null;
+ containerPage.remove();
+ });
+
+ showModal = function() {
+ modal = new MoveXBlockModal({
+ sourceXBlockInfo: new XBlockInfo({
+ id: sourceLocator,
+ display_name: sourceDisplayName,
+ category: 'component'
+ }),
+ sourceParentXBlockInfo: sourceParentXBlockInfo,
+ XBlockUrlRoot: '/xblock'
+ });
+ modal.show();
+ };
+
+ /**
+ * Create child XBlock info.
+ *
+ * @param {String} category XBlock category
+ * @param {Object} outlineOptions options according to which outline was created
+ * @param {Object} xblockIndex XBlock Index
+ * @returns
+ */
+ createChildXBlockInfo = function(category, outlineOptions, xblockIndex) {
+ var childInfo = {
+ category: categoryMap[category],
+ display_name: category + '_display_name_' + xblockIndex,
+ id: category + '_ID_' + xblockIndex
+ };
+ return createXBlockInfo(parentChildMap[category], outlineOptions, childInfo);
+ };
+
+ /**
+ * Create parent XBlock info.
+ *
+ * @param {String} category XBlock category
+ * @param {Object} outlineOptions options according to which outline was created
+ * @param {Object} outline ouline info being constructed
+ * @returns {Object}
+ */
+ createXBlockInfo = function(category, outlineOptions, outline) {
+ var childInfo = {
+ category: categoryMap[category],
+ display_name: category,
+ children: []
+ },
+ xblocks;
+
+ xblocks = outlineOptions[category];
+ if (!xblocks) {
+ return outline;
+ }
+
+ outline.child_info = childInfo; // eslint-disable-line no-param-reassign
+ _.each(_.range(xblocks), function(xblockIndex) {
+ childInfo.children.push(
+ createChildXBlockInfo(category, outlineOptions, xblockIndex)
+ );
+ });
+ return outline;
+ };
+
+ /**
+ * Create course outline.
+ *
+ * @param {Object} outlineOptions options according to which outline was created
+ * @returns {Object}
+ */
+ createCourseOutline = function(outlineOptions) {
+ var courseXBlockInfo = {
+ category: 'course',
+ display_name: 'Demo Course',
+ id: 'COURSE_ID_101'
+ };
+ return createXBlockInfo('section', outlineOptions, courseXBlockInfo);
+ };
+
+ /**
+ * Render breadcrumb and XBlock list view.
+ *
+ * @param {any} courseOutlineInfo course outline info
+ * @param {any} ancestorInfo ancestors info
+ */
+ renderViews = function(courseOutlineInfo, ancestorInfo) {
+ var ancestorInfo = ancestorInfo || {ancestors: []}; // eslint-disable-line no-redeclare
+ modal.renderViews(courseOutlineInfo, ancestorInfo);
+ };
+
+ /**
+ * Extract displayed XBlock list info.
+ *
+ * @returns {Object}
+ */
+ getDisplayedInfo = function() {
+ var viewEl = modal.moveXBlockListView.$el;
+ return {
+ categoryText: viewEl.find('.category-text').text().trim(),
+ currentLocationText: viewEl.find('.current-location').text().trim(),
+ xblockCount: viewEl.find('.xblock-item').length,
+ xblockDisplayNames: viewEl.find('.xblock-item .xblock-displayname').map(
+ function() { return $(this).text().trim(); }
+ ).get(),
+ forwardButtonSRTexts: viewEl.find('.xblock-item .forward-sr-text').map(
+ function() { return $(this).text().trim(); }
+ ).get(),
+ forwardButtonCount: viewEl.find('.fa-arrow-right.forward-sr-icon').length
+ };
+ };
+
+ /**
+ * Verify displayed XBlock list info.
+ *
+ * @param {String} category XBlock category
+ * @param {Integer} expectedXBlocksCount number of XBlock childs displayed
+ * @param {Boolean} hasCurrentLocation do we need to check current location
+ */
+ verifyListViewInfo = function(category, expectedXBlocksCount, hasCurrentLocation) {
+ var displayedInfo = getDisplayedInfo();
+ expect(displayedInfo.categoryText).toEqual(modal.moveXBlockListView.categoriesText[category] + ':');
+ expect(displayedInfo.xblockCount).toEqual(expectedXBlocksCount);
+ expect(displayedInfo.xblockDisplayNames).toEqual(
+ _.map(_.range(expectedXBlocksCount), function(xblockIndex) {
+ return category + '_display_name_' + xblockIndex;
+ })
+ );
+ if (category === 'component') {
+ if (hasCurrentLocation) {
+ expect(displayedInfo.currentLocationText).toEqual('(Currently selected)');
+ }
+ } else {
+ if (hasCurrentLocation) {
+ expect(displayedInfo.currentLocationText).toEqual('(Current location)');
+ }
+ expect(displayedInfo.forwardButtonSRTexts).toEqual(
+ _.map(_.range(expectedXBlocksCount), function() {
+ return 'View child items';
+ })
+ );
+ expect(displayedInfo.forwardButtonCount).toEqual(expectedXBlocksCount);
+ }
+ };
+
+ /**
+ * Verify rendered breadcrumb info.
+ *
+ * @param {any} category XBlock category
+ * @param {any} xblockIndex XBlock index
+ */
+ verifyBreadcrumbViewInfo = function(category, xblockIndex) {
+ var displayedBreadcrumbs = modal.moveXBlockBreadcrumbView.$el.find('.breadcrumbs .bc-container').map(
+ function() { return $(this).text().trim(); }
+ ).get(),
+ categories = _.keys(parentChildMap).concat(['component']),
+ visitedCategories = categories.slice(0, _.indexOf(categories, category));
+
+ expect(displayedBreadcrumbs).toEqual(
+ _.map(visitedCategories, function(visitedCategory) {
+ return visitedCategory === 'course' ?
+ 'Course Outline' : visitedCategory + '_display_name_' + xblockIndex;
+ })
+ );
+ };
+
+ /**
+ * Click forward button in the list of displayed XBlocks.
+ *
+ * @param {any} buttonIndex forward button index
+ */
+ clickForwardButton = function(buttonIndex) {
+ buttonIndex = buttonIndex || 0; // eslint-disable-line no-param-reassign
+ modal.moveXBlockListView.$el.find('[data-item-index="' + buttonIndex + '"] button').click();
+ };
+
+ /**
+ * Click on last clickable breadcrumb button.
+ */
+ clickBreadcrumbButton = function() {
+ modal.moveXBlockBreadcrumbView.$el.find('.bc-container button').last().click();
+ };
+
+ /**
+ * Returns the parent or child category of current XBlock.
+ *
+ * @param {String} direction `forward` or `backward`
+ * @param {String} category XBlock category
+ * @returns {String}
+ */
+ nextCategory = function(direction, category) {
+ return direction === 'forward' ? parentChildMap[category] : _.invert(parentChildMap)[category];
+ };
+
+ /**
+ * Verify renderd info of breadcrumbs and XBlock list.
+ *
+ * @param {Object} outlineOptions options according to which outline was created
+ * @param {String} category XBlock category
+ * @param {Integer} buttonIndex forward button index
+ * @param {String} direction `forward` or `backward`
+ * @param {String} hasCurrentLocation do we need to check current location
+ * @returns
+ */
+ verifyXBlockInfo = function(outlineOptions, category, buttonIndex, direction, hasCurrentLocation) {
+ var expectedXBlocksCount = outlineOptions[category];
+
+ verifyListViewInfo(category, expectedXBlocksCount, hasCurrentLocation);
+ verifyBreadcrumbViewInfo(category, buttonIndex);
+ verifyMoveEnabled(category, hasCurrentLocation);
+
+ if (direction === 'forward') {
+ if (category === 'component') {
+ return;
+ }
+ clickForwardButton(buttonIndex);
+ } else if (direction === 'backward') {
+ if (category === 'section') {
+ return;
+ }
+ clickBreadcrumbButton();
+ }
+ category = nextCategory(direction, category); // eslint-disable-line no-param-reassign
+
+ verifyXBlockInfo(outlineOptions, category, buttonIndex, direction, hasCurrentLocation);
+ };
+
+ /**
+ * Verify move button is enabled.
+ *
+ * @param {String} category XBlock category
+ * @param {String} hasCurrentLocation do we need to check current location
+ */
+ verifyMoveEnabled = function(category, hasCurrentLocation) {
+ var isMoveEnabled = !modal.$el.find('.modal-actions .action-move').hasClass('is-disabled');
+ if (category === 'component' && !hasCurrentLocation) {
+ expect(isMoveEnabled).toBeTruthy();
+ } else {
+ expect(isMoveEnabled).toBeFalsy();
+ }
+ };
+
+ /**
+ * Verify notification status.
+ *
+ * @param {Object} requests requests object
+ * @param {Object} notificationSpy notification spy
+ * @param {String} notificationText notification text to be verified
+ * @param {Integer} sourceIndex source index of the xblock
+ */
+ verifyNotificationStatus = function(requests, notificationSpy, notificationText, sourceIndex) {
+ var sourceIndex = sourceIndex || 0; // eslint-disable-line no-redeclare
+ ViewHelpers.verifyNotificationShowing(notificationSpy, notificationText);
+ AjaxHelpers.respondWithJson(requests, {
+ move_source_locator: sourceLocator,
+ parent_locator: sourceParentLocator,
+ target_index: sourceIndex
+ });
+ ViewHelpers.verifyNotificationHidden(notificationSpy);
+ };
+
+ /**
+ * Get move alert confirmation message HTML
+ */
+ getMovedAlertNotification = function() {
+ return $('#page-alert');
+ };
+
+ /**
+ * Send move xblock request.
+ *
+ * @param {Object} requests requests object
+ * @param {Object} xblockLocator Xblock id location
+ * @param {Integer} targetIndex target index of the xblock
+ * @param {Integer} sourceIndex source index of the xblock
+ */
+ sendMoveXBlockRequest = function(requests, xblockLocator, targetIndex, sourceIndex) {
+ var responseData,
+ expectedData,
+ sourceIndex = sourceIndex || 0; // eslint-disable-line no-redeclare
+
+ responseData = expectedData = {
+ move_source_locator: xblockLocator,
+ parent_locator: modal.targetParentXBlockInfo.id
+ };
+
+ if (targetIndex !== undefined) {
+ expectedData = _.extend(expectedData, {
+ targetIndex: targetIndex
+ });
+ }
+
+ // verify content of request
+ AjaxHelpers.expectJsonRequest(requests, 'PATCH', '/xblock/', expectedData);
+
+ // send the response
+ AjaxHelpers.respondWithJson(requests, _.extend(responseData, {
+ source_index: sourceIndex
+ }));
+ };
+
+ /**
+ * Move xblock with success.
+ *
+ * @param {Object} requests requests object
+ */
+ moveXBlockWithSuccess = function(requests) {
+ // select a target item and click
+ renderViews(courseOutline);
+ _.each(_.range(3), function() {
+ clickForwardButton(1);
+ });
+ modal.$el.find('.modal-actions .action-move').click();
+ sendMoveXBlockRequest(requests, sourceLocator);
+ AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/' + sourceParentLocator);
+ AjaxHelpers.respondWithJson(requests, sourceParentXBlockInfo);
+ expect(getMovedAlertNotification().html().length).not.toEqual(0);
+ verifyConfirmationFeedbackTitleText(sourceDisplayName);
+ verifyConfirmationFeedbackRedirectLinkText();
+ verifyConfirmationFeedbackUndoMoveActionText();
+ };
+
+ /**
+ * Verify success banner message html has correct title text.
+ *
+ * @param {String} displayName XBlock display name
+ */
+ verifyConfirmationFeedbackTitleText = function(displayName) {
+ expect(getMovedAlertNotification().find('.title').html()
+ .trim())
+ .toEqual(StringUtils.interpolate('Success! "{displayName}" has been moved.',
+ {
+ displayName: displayName
+ })
+ );
+ };
+
+ /**
+ * Verify undo success banner message html has correct title text.
+ *
+ * @param {String} displayName XBlock display name
+ */
+ verifyUndoConfirmationFeedbackTitleText = function(displayName) {
+ expect(getMovedAlertNotification().find('.title').html()).toEqual(
+ StringUtils.interpolate(
+ 'Move cancelled. "{sourceDisplayName}" has been moved back to its original location.',
+ {
+ sourceDisplayName: displayName
+ }
+ )
+ );
+ };
+
+ /**
+ * Verify success banner message html has correct redirect link text.
+ */
+ verifyConfirmationFeedbackRedirectLinkText = function() {
+ expect(getMovedAlertNotification().find('.nav-actions .action-secondary').html())
+ .toEqual('Take me to the new location');
+ };
+
+ /**
+ * Verify success banner message html has correct undo move text.
+ */
+ verifyConfirmationFeedbackUndoMoveActionText = function() {
+ expect(getMovedAlertNotification().find('.nav-actions .action-primary').html()).toEqual('Undo move');
+ };
+
+ /**
+ * Get sent requests.
+ *
+ * @returns {Object}
+ */
+ getSentRequests = function() {
+ return jasmine.Ajax.requests.filter(function(request) {
+ return request.readyState > 0;
+ });
+ };
+
+ it('renders views with correct information', function() {
+ var outlineOptions = {section: 1, subsection: 1, unit: 1, component: 1},
+ outline = createCourseOutline(outlineOptions);
+
+ renderViews(outline, xblockAncestorInfo);
+ verifyXBlockInfo(outlineOptions, 'section', 0, 'forward', true);
+ verifyXBlockInfo(outlineOptions, 'component', 0, 'backward', true);
+ });
+
+ it('shows correct behavior on breadcrumb navigation', function() {
+ var outline = createCourseOutline({section: 1, subsection: 1, unit: 1, component: 1});
+
+ renderViews(outline);
+ _.each(_.range(3), function() {
+ clickForwardButton();
+ });
+
+ _.each(['component', 'unit', 'subsection', 'section'], function(category) {
+ verifyListViewInfo(category, 1);
+ if (category !== 'section') {
+ modal.moveXBlockBreadcrumbView.$el.find('.bc-container button').last().click();
+ }
+ });
+ });
+
+ it('shows the correct current location', function() {
+ var outlineOptions = {section: 2, subsection: 2, unit: 2, component: 2},
+ outline = createCourseOutline(outlineOptions);
+ renderViews(outline, xblockAncestorInfo);
+ verifyXBlockInfo(outlineOptions, 'section', 0, 'forward', true);
+ // click the outline breadcrumb to render sections
+ modal.moveXBlockBreadcrumbView.$el.find('.bc-container button').first().click();
+ verifyXBlockInfo(outlineOptions, 'section', 1, 'forward', false);
+ });
+
+ it('shows correct message when parent has no children', function() {
+ var outlinesInfo = [
+ {
+ outline: createCourseOutline({}),
+ message: 'This course has no sections'
+ },
+ {
+ outline: createCourseOutline({section: 1}),
+ message: 'This section has no subsections',
+ forwardClicks: 1
+ },
+ {
+ outline: createCourseOutline({section: 1, subsection: 1}),
+ message: 'This subsection has no units',
+ forwardClicks: 2
+ },
+ {
+ outline: createCourseOutline({section: 1, subsection: 1, unit: 1}),
+ message: 'This unit has no components',
+ forwardClicks: 3
+ }
+ ];
+
+ _.each(outlinesInfo, function(info) {
+ renderViews(info.outline);
+ _.each(_.range(info.forwardClicks), function() {
+ clickForwardButton();
+ });
+ expect(modal.moveXBlockListView.$el.find('.xblock-no-child-message').text().trim())
+ .toEqual(info.message);
+ modal.moveXBlockListView.undelegateEvents();
+ modal.moveXBlockBreadcrumbView.undelegateEvents();
+ });
+ });
+
+ describe('Move button', function() {
+ it('is disabled when navigating to same parent', function() {
+ // select a target parent as the same as source parent and click
+ renderViews(courseOutline);
+ _.each(_.range(3), function() {
+ clickForwardButton(0);
+ });
+ verifyMoveEnabled('component', true);
+ });
+
+ it('is enabled when navigating to different parent', function() {
+ // select a target parent as the different as source parent and click
+ renderViews(courseOutline);
+ _.each(_.range(3), function() {
+ clickForwardButton(1);
+ });
+ verifyMoveEnabled('component', false);
+ });
+
+ it('verify move state while navigating', function() {
+ renderViews(courseOutline, xblockAncestorInfo);
+ verifyXBlockInfo(courseOutlineOptions, 'section', 0, 'forward', true);
+ // start from course outline again
+ modal.moveXBlockBreadcrumbView.$el.find('.bc-container button').first().click();
+ verifyXBlockInfo(courseOutlineOptions, 'section', 1, 'forward', false);
+ });
+
+ it('is disbabled when navigating to same source xblock', function() {
+ var outline,
+ libraryContentXBlockInfo = {
+ category: 'library_content',
+ display_name: 'Library Content',
+ has_children: true,
+ id: 'LIBRARY_CONTENT_ID'
+ },
+ outlineOptions = {library_content: 1, component: 1};
+
+ // make above xblock source xblock.
+ modal.sourceXBlockInfo = libraryContentXBlockInfo;
+ outline = createXBlockInfo('component', outlineOptions, libraryContentXBlockInfo);
+ renderViews(outline);
+ expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy();
+
+ // select a target parent
+ clickForwardButton(0);
+ expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy();
+ });
+
+ it('is disabled when navigating inside source content experiment', function() {
+ var outline,
+ splitTestXBlockInfo = {
+ category: 'split_test',
+ display_name: 'Content Experiment',
+ has_children: true,
+ id: 'SPLIT_TEST_ID'
+ },
+ outlineOptions = {split_test: 1, unit: 2, component: 1};
+
+ // make above xblock source xblock.
+ modal.sourceXBlockInfo = splitTestXBlockInfo;
+ outline = createXBlockInfo('unit', outlineOptions, splitTestXBlockInfo);
+ renderViews(outline);
+ expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy();
+
+ // navigate to groups level
+ clickForwardButton(0);
+ expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy();
+
+ // navigate to component level inside a group
+ clickForwardButton(0);
+
+ // move should be disabled because we are navigating inside source xblock
+ expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy();
+ });
+
+ it('is disabled when navigating to any content experiment groups', function() {
+ var outline,
+ splitTestXBlockInfo = {
+ category: 'split_test',
+ display_name: 'Content Experiment',
+ has_children: true,
+ id: 'SPLIT_TEST_ID'
+ },
+ outlineOptions = {split_test: 1, unit: 2, component: 1};
+
+ // group level should be disabled but component level inside groups should be movable
+ outline = createXBlockInfo('unit', outlineOptions, splitTestXBlockInfo);
+ renderViews(outline);
+
+ // move is disabled on groups level
+ expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy();
+
+ // navigate to component level inside a group
+ clickForwardButton(1);
+ expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeFalsy();
+ });
+
+ it('is enabled when navigating to any parentable component', function() {
+ var parentableXBlockInfo = {
+ category: 'vertical',
+ display_name: 'Parentable Component',
+ has_children: true,
+ id: 'PARENTABLE_ID'
+ };
+ renderViews(parentableXBlockInfo);
+
+ // move is enabled on parentable xblocks.
+ expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeFalsy();
+ });
+
+ it('is enabled when moving a component inside a parentable component', function() {
+ // create a source parent with has_childern set true
+ modal.sourceParentXBlockInfo = new XBlockInfo({
+ category: 'conditional',
+ display_name: 'Parentable Component',
+ has_children: true,
+ id: 'PARENTABLE_ID'
+ });
+ // navigate and verify move button is enabled
+ renderViews(courseOutline);
+ _.each(_.range(3), function() {
+ clickForwardButton(0);
+ });
+
+ // move is enabled when moving a component.
+ expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeFalsy();
+ });
+
+ it('is disabled when navigating to any non-parentable component', function() {
+ var nonParentableXBlockInfo = {
+ category: 'html',
+ display_name: 'Non Parentable Component',
+ has_children: false,
+ id: 'NON_PARENTABLE_ID'
+ };
+ renderViews(nonParentableXBlockInfo);
+
+ // move is disabled on non-parent xblocks.
+ expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy();
+ });
+ });
+
+ describe('Move an xblock', function() {
+ it('can not move in a disabled state', function() {
+ verifyMoveEnabled(false);
+ modal.$el.find('.modal-actions .action-move').click();
+ expect(getMovedAlertNotification().html().length).toEqual(0);
+ expect(getSentRequests().length).toEqual(0);
+ });
+
+ it('move an xblock when move button is clicked', function() {
+ var requests = AjaxHelpers.requests(this);
+ moveXBlockWithSuccess(requests);
+ });
+
+ it('do not move an xblock when cancel button is clicked', function() {
+ modal.$el.find('.modal-actions .action-cancel').click();
+ expect(getMovedAlertNotification().html().length).toEqual(0);
+ expect(getSentRequests().length).toEqual(0);
+ });
+
+ it('undo move an xblock when undo move link is clicked', function() {
+ var sourceIndex = 0,
+ requests = AjaxHelpers.requests(this);
+ moveXBlockWithSuccess(requests);
+ getMovedAlertNotification().find('.action-save').click();
+ AjaxHelpers.respondWithJson(requests, {
+ move_source_locator: sourceLocator,
+ parent_locator: sourceParentLocator,
+ target_index: sourceIndex
+ });
+ verifyUndoConfirmationFeedbackTitleText(sourceDisplayName);
+ });
+ });
+
+ describe('shows a notification', function() {
+ it('mini operation message when moving an xblock', function() {
+ var requests = AjaxHelpers.requests(this),
+ notificationSpy = ViewHelpers.createNotificationSpy();
+ // navigate to a target parent and click
+ renderViews(courseOutline);
+ _.each(_.range(3), function() {
+ clickForwardButton(1);
+ });
+ modal.$el.find('.modal-actions .action-move').click();
+ verifyNotificationStatus(requests, notificationSpy, 'Moving');
+ });
+
+ it('mini operation message when undo moving an xblock', function() {
+ var notificationSpy,
+ requests = AjaxHelpers.requests(this);
+ moveXBlockWithSuccess(requests);
+ notificationSpy = ViewHelpers.createNotificationSpy();
+ getMovedAlertNotification().find('.action-save').click();
+ verifyNotificationStatus(requests, notificationSpy, 'Undo moving');
+ });
+
+ it('error message when move request fails', function() {
+ var requests = AjaxHelpers.requests(this),
+ notificationSpy = ViewHelpers.createNotificationSpy('Error');
+ // select a target item and click
+ renderViews(courseOutline);
+ _.each(_.range(3), function() {
+ clickForwardButton(1);
+ });
+ modal.$el.find('.modal-actions .action-move').click();
+ AjaxHelpers.respondWithError(requests);
+ ViewHelpers.verifyNotificationShowing(notificationSpy, "Studio's having trouble saving your work");
+ });
+
+ it('error message when undo move request fails', function() {
+ var requests = AjaxHelpers.requests(this),
+ notificationSpy = ViewHelpers.createNotificationSpy('Error');
+ moveXBlockWithSuccess(requests);
+ getMovedAlertNotification().find('.action-save').click();
+ AjaxHelpers.respondWithError(requests);
+ ViewHelpers.verifyNotificationShowing(notificationSpy, "Studio's having trouble saving your work");
+ });
+ });
+ });
+ });
diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js
index 1e378ab7c617..4b56b837b66c 100644
--- a/cms/static/js/spec/views/pages/container_spec.js
+++ b/cms/static/js/spec/views/pages/container_spec.js
@@ -20,7 +20,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
mockXBlockVisibilityEditorHtml = readFixtures('mock/mock-xblock-visibility-editor.underscore'),
PageClass = globalPageOptions.page,
pagedSpecificTests = globalPageOptions.pagedSpecificTests,
- hasVisibilityEditor = globalPageOptions.hasVisibilityEditor;
+ hasVisibilityEditor = globalPageOptions.hasVisibilityEditor,
+ hasMoveModal = globalPageOptions.hasMoveModal;
beforeEach(function() {
var newDisplayName = 'New Display Name';
@@ -48,6 +49,9 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
afterEach(function() {
EditHelpers.uninstallMockXBlock();
+ if (containerPage !== undefined) {
+ containerPage.remove();
+ }
});
respondWithHtml = function(html) {
@@ -250,6 +254,19 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
expect(visibilityButtons.length).toBe(0);
}
});
+
+ it('can show a move modal for a child xblock', function() {
+ var moveButtons;
+ renderContainerPage(this, mockContainerXBlockHtml);
+ moveButtons = containerPage.$('.wrapper-xblock .move-button');
+ if (hasMoveModal) {
+ expect(moveButtons.length).toBe(6);
+ moveButtons[0].click();
+ expect(EditHelpers.isShowingModal()).toBeTruthy();
+ } else {
+ expect(moveButtons.length).toBe(0);
+ }
+ });
});
describe('Editing an xmodule', function() {
@@ -798,7 +815,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
initial: 'mock/mock-container-xblock.underscore',
addResponse: 'mock/mock-xblock.underscore',
hasVisibilityEditor: true,
- pagedSpecificTests: false
+ pagedSpecificTests: false,
+ hasMoveModal: true
}
);
@@ -811,7 +829,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
initial: 'mock/mock-container-paged-xblock.underscore',
addResponse: 'mock/mock-xblock-paged.underscore',
hasVisibilityEditor: false,
- pagedSpecificTests: true
+ pagedSpecificTests: true,
+ hasMoveModal: 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 947e73c1dc13..13bfc8d3e271 100644
--- a/cms/static/js/spec/views/pages/container_subviews_spec.js
+++ b/cms/static/js/spec/views/pages/container_subviews_spec.js
@@ -35,6 +35,9 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
afterEach(function() {
delete window.course;
+ if (containerPage !== undefined) {
+ containerPage.remove();
+ }
});
defaultXBlockInfo = {
diff --git a/cms/static/js/views/modals/base_modal.js b/cms/static/js/views/modals/base_modal.js
index 33dd66fd5057..2974a7720a45 100644
--- a/cms/static/js/views/modals/base_modal.js
+++ b/cms/static/js/views/modals/base_modal.js
@@ -16,8 +16,11 @@
* 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
+ * addPrimaryActionButton: A boolean indicating whether to include a primary action
* button on the modal.
+ * primaryActionButtonType: A string to be used as type for primary action button.
+ * primaryActionButtonTitle: A string to be used as title for primary action button.
+ * showEditorModeButtons: Whether to show editor mode button in the modal header.
*/
define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
function($, _, gettext, BaseView) {
@@ -36,7 +39,11 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
title: '',
modalWindowClass: '.modal-window',
// A list of class names, separated by space.
- viewSpecificClasses: ''
+ viewSpecificClasses: '',
+ addPrimaryActionButton: false,
+ primaryActionButtonType: 'save',
+ primaryActionButtonTitle: gettext('Save'),
+ showEditorModeButtons: true
}),
initialize: function() {
@@ -61,6 +68,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
type: this.options.modalType,
size: this.options.modalSize,
title: this.getTitle(),
+ modalSRTitle: this.options.modalSRTitle,
+ showEditorModeButtons: this.options.showEditorModeButtons,
viewSpecificClasses: this.options.viewSpecificClasses
}));
this.addActionButtons();
@@ -84,14 +93,17 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
return '';
},
- show: function() {
+ show: function(focusModal) {
+ var focusModalWindow = focusModal === undefined;
this.render();
this.resize();
$(window).resize(_.bind(this.resize, this));
- // after showing and resizing, send focus
- var modal = this.$el.find(this.options.modalWindowClass);
- modal.focus();
+ // child may want to have its own focus management
+ if (focusModalWindow) {
+ // after showing and resizing, send focus
+ this.$el.find(this.options.modalWindowClass).focus();
+ }
},
hide: function() {
@@ -112,8 +124,12 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
* Adds the action buttons to the modal.
*/
addActionButtons: function() {
- if (this.options.addSaveButton) {
- this.addActionButton('save', gettext('Save'), true);
+ if (this.options.addPrimaryActionButton) {
+ this.addActionButton(
+ this.options.primaryActionButtonType,
+ this.options.primaryActionButtonTitle,
+ true
+ );
}
this.addActionButton('cancel', gettext('Cancel'));
},
diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js
index 8bfe52922018..2c48c7e469c7 100644
--- a/cms/static/js/views/modals/course_outline_modals.js
+++ b/cms/static/js/views/modals/course_outline_modals.js
@@ -25,7 +25,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
options: $.extend({}, BaseModal.prototype.options, {
modalName: 'course-outline',
modalType: 'edit-settings',
- addSaveButton: true,
+ addPrimaryActionButton: true,
modalSize: 'med',
viewSpecificClasses: 'confirm',
editors: []
diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js
index 5ffd036504f1..2576b8dbbdb0 100644
--- a/cms/static/js/views/modals/edit_xblock.js
+++ b/cms/static/js/views/modals/edit_xblock.js
@@ -4,9 +4,9 @@
* and upon save an optional refresh function can be invoked to update the display.
*/
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common/js/components/utils/view_utils',
- 'js/models/xblock_info', 'js/views/xblock_editor'],
- function($, _, gettext, BaseModal, ViewUtils, XBlockInfo, XBlockEditorView) {
- 'strict mode';
+ 'js/views/utils/xblock_utils', 'js/views/xblock_editor'],
+ function($, _, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockEditorView) {
+ 'use strict';
var EditXBlockModal = BaseModal.extend({
events: _.extend({}, BaseModal.prototype.events, {
@@ -16,11 +16,11 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
options: $.extend({}, BaseModal.prototype.options, {
modalName: 'edit-xblock',
- addSaveButton: true,
view: 'studio_view',
viewSpecificClasses: 'modal-editor confirm',
// Translators: "title" is the name of the current component being edited.
- titleFormat: gettext('Editing: %(title)s')
+ titleFormat: gettext('Editing: %(title)s'),
+ addPrimaryActionButton: true
}),
initialize: function() {
@@ -37,7 +37,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
*/
edit: function(xblockElement, rootXBlockInfo, options) {
this.xblockElement = xblockElement;
- this.xblockInfo = this.findXBlockInfo(xblockElement, rootXBlockInfo);
+ this.xblockInfo = XBlockViewUtils.findXBlockInfo(xblockElement, rootXBlockInfo);
this.options.modalType = this.xblockInfo.get('category');
this.editOptions = options;
this.render();
@@ -183,28 +183,6 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
this.editorView.notifyRuntime('modal-hidden');
},
- findXBlockInfo: function(xblockWrapperElement, defaultXBlockInfo) {
- var xblockInfo = defaultXBlockInfo,
- xblockElement,
- displayName;
- if (xblockWrapperElement.length > 0) {
- xblockElement = xblockWrapperElement.find('.xblock');
- displayName = xblockWrapperElement.find('.xblock-header .header-details .xblock-display-name').text().trim();
- // If not found, try looking for the old unit page style rendering.
- // Only used now by static pages.
- if (!displayName) {
- displayName = this.xblockElement.find('.component-header').text().trim();
- }
- xblockInfo = new XBlockInfo({
- id: xblockWrapperElement.data('locator'),
- courseKey: xblockWrapperElement.data('course-key'),
- category: xblockElement.data('block-type'),
- display_name: displayName
- });
- }
- return xblockInfo;
- },
-
addModeButton: function(mode, displayName) {
var buttonPanel = this.$('.editor-modes');
buttonPanel.append(this.editorModeButtonTemplate({
diff --git a/cms/static/js/views/modals/move_xblock_modal.js b/cms/static/js/views/modals/move_xblock_modal.js
new file mode 100644
index 000000000000..2da157b4be96
--- /dev/null
+++ b/cms/static/js/views/modals/move_xblock_modal.js
@@ -0,0 +1,188 @@
+/**
+ * The MoveXblockModal to move XBlocks in course.
+ */
+define([
+ 'jquery',
+ 'backbone',
+ 'underscore',
+ 'gettext',
+ 'js/views/baseview',
+ 'js/views/utils/xblock_utils',
+ 'js/views/utils/move_xblock_utils',
+ 'edx-ui-toolkit/js/utils/html-utils',
+ 'edx-ui-toolkit/js/utils/string-utils',
+ 'common/js/components/views/feedback',
+ 'js/models/xblock_info',
+ 'js/views/modals/base_modal',
+ 'js/views/move_xblock_list',
+ 'js/views/move_xblock_breadcrumb',
+ 'text!templates/move-xblock-modal.underscore'
+],
+function($, Backbone, _, gettext, BaseView, XBlockViewUtils, MoveXBlockUtils, HtmlUtils, StringUtils, Feedback,
+ XBlockInfoModel, BaseModal, MoveXBlockListView, MoveXBlockBreadcrumbView, MoveXblockModalTemplate) {
+ 'use strict';
+
+ var MoveXblockModal = BaseModal.extend({
+ events: _.extend({}, BaseModal.prototype.events, {
+ 'click .action-move:not(.is-disabled)': 'moveXBlock'
+ }),
+
+ options: $.extend({}, BaseModal.prototype.options, {
+ modalName: 'move-xblock',
+ modalSize: 'lg',
+ showEditorModeButtons: false,
+ addPrimaryActionButton: true,
+ primaryActionButtonType: 'move',
+ viewSpecificClasses: 'move-modal',
+ primaryActionButtonTitle: gettext('Move'),
+ modalSRTitle: gettext('Choose a location to move your component to')
+ }),
+
+ initialize: function() {
+ var self = this;
+ BaseModal.prototype.initialize.call(this);
+ this.sourceXBlockInfo = this.options.sourceXBlockInfo;
+ this.sourceParentXBlockInfo = this.options.sourceParentXBlockInfo;
+ this.targetParentXBlockInfo = null;
+ this.XBlockURLRoot = this.options.XBlockURLRoot;
+ this.XBlockAncestorInfoURL = StringUtils.interpolate(
+ '{urlRoot}/{usageId}?fields=ancestorInfo',
+ {urlRoot: this.XBlockURLRoot, usageId: this.sourceXBlockInfo.get('id')}
+ );
+ this.outlineURL = this.options.outlineURL;
+ this.options.title = this.getTitle();
+ this.fetchCourseOutline().done(function(courseOutlineInfo, ancestorInfo) {
+ $('.ui-loading').addClass('is-hidden');
+ $('.breadcrumb-container').removeClass('is-hidden');
+ self.renderViews(courseOutlineInfo, ancestorInfo);
+ });
+ this.listenTo(Backbone, 'move:breadcrumbRendered', this.focusModal);
+ this.listenTo(Backbone, 'move:enableMoveOperation', this.enableMoveOperation);
+ this.listenTo(Backbone, 'move:hideMoveModal', this.hide);
+ },
+
+ getTitle: function() {
+ return StringUtils.interpolate(
+ gettext('Move: {displayName}'),
+ {displayName: this.sourceXBlockInfo.get('display_name')}
+ );
+ },
+
+ getContentHtml: function() {
+ return _.template(MoveXblockModalTemplate)({});
+ },
+
+ show: function() {
+ BaseModal.prototype.show.apply(this, [false]);
+ this.updateMoveState(false);
+ MoveXBlockUtils.hideMovedNotification();
+ },
+
+ hide: function() {
+ if (this.moveXBlockListView) {
+ this.moveXBlockListView.remove();
+ }
+ if (this.moveXBlockBreadcrumbView) {
+ this.moveXBlockBreadcrumbView.remove();
+ }
+ BaseModal.prototype.hide.apply(this);
+ Feedback.prototype.outFocus.apply(this);
+ },
+
+ focusModal: function() {
+ Feedback.prototype.inFocus.apply(this, [this.options.modalWindowClass]);
+ $(this.options.modalWindowClass).focus();
+ },
+
+ fetchCourseOutline: function() {
+ return $.when(
+ this.fetchData(this.outlineURL),
+ this.fetchData(this.XBlockAncestorInfoURL)
+ );
+ },
+
+ fetchData: function(url) {
+ var deferred = $.Deferred();
+ $.ajax({
+ url: url,
+ contentType: 'application/json',
+ dataType: 'json',
+ type: 'GET'
+ }).done(function(data) {
+ deferred.resolve(data);
+ }).fail(function() {
+ deferred.reject();
+ });
+ return deferred.promise();
+ },
+
+ renderViews: function(courseOutlineInfo, ancestorInfo) {
+ this.moveXBlockBreadcrumbView = new MoveXBlockBreadcrumbView({});
+ this.moveXBlockListView = new MoveXBlockListView(
+ {
+ model: new XBlockInfoModel(courseOutlineInfo, {parse: true}),
+ sourceXBlockInfo: this.sourceXBlockInfo,
+ ancestorInfo: ancestorInfo
+ }
+ );
+ },
+
+ updateMoveState: function(isValidMove) {
+ var $moveButton = this.$el.find('.action-move');
+ if (isValidMove) {
+ $moveButton.removeClass('is-disabled');
+ } else {
+ $moveButton.addClass('is-disabled');
+ }
+ },
+
+ isValidCategory: function(targetParentXBlockInfo) {
+ var basicBlockTypes = ['course', 'chapter', 'sequential', 'vertical'],
+ sourceParentType = this.sourceParentXBlockInfo.get('category'),
+ targetParentType = targetParentXBlockInfo.get('category'),
+ sourceParentHasChildren = this.sourceParentXBlockInfo.get('has_children'),
+ targetParentHasChildren = targetParentXBlockInfo.get('has_children');
+
+ // Treat source parent component as vertical to support move child components under content experiment
+ // and other similar xblocks.
+ if (sourceParentHasChildren && !_.contains(basicBlockTypes, sourceParentType)) {
+ sourceParentType = 'vertical'; // eslint-disable-line no-param-reassign
+ }
+
+ // Treat target parent component as a vertical to support move to parentable target parent components.
+ // Also, moving a component directly to content experiment is not allowed, we need to visit to group level.
+ if (targetParentHasChildren && !_.contains(basicBlockTypes, targetParentType) &&
+ targetParentType !== 'split_test') {
+ targetParentType = 'vertical'; // eslint-disable-line no-param-reassign
+ }
+ return targetParentType === sourceParentType;
+ },
+
+ enableMoveOperation: function(targetParentXBlockInfo) {
+ var isValidMove = false;
+
+ // update target parent on navigation
+ this.targetParentXBlockInfo = targetParentXBlockInfo;
+ if (this.isValidCategory(targetParentXBlockInfo) &&
+ this.sourceParentXBlockInfo.id !== targetParentXBlockInfo.id && // same parent case
+ this.sourceXBlockInfo.id !== targetParentXBlockInfo.id) { // same source item case
+ isValidMove = true;
+ }
+ this.updateMoveState(isValidMove);
+ },
+
+ moveXBlock: function() {
+ MoveXBlockUtils.moveXBlock(
+ {
+ sourceXBlockElement: $("li.studio-xblock-wrapper[data-locator='" + this.sourceXBlockInfo.id + "']"),
+ sourceDisplayName: this.sourceXBlockInfo.get('display_name'),
+ sourceLocator: this.sourceXBlockInfo.id,
+ sourceParentLocator: this.sourceParentXBlockInfo.id,
+ targetParentLocator: this.targetParentXBlockInfo.id
+ }
+ );
+ }
+ });
+
+ return MoveXblockModal;
+});
diff --git a/cms/static/js/views/move_xblock_breadcrumb.js b/cms/static/js/views/move_xblock_breadcrumb.js
new file mode 100644
index 000000000000..d4f5b90d00db
--- /dev/null
+++ b/cms/static/js/views/move_xblock_breadcrumb.js
@@ -0,0 +1,48 @@
+/**
+ * MoveXBlockBreadcrumb show breadcrumbs to move back to parent.
+ */
+define([
+ 'jquery', 'backbone', 'underscore', 'gettext',
+ 'edx-ui-toolkit/js/utils/html-utils',
+ 'edx-ui-toolkit/js/utils/string-utils',
+ 'text!templates/move-xblock-breadcrumb.underscore'
+],
+function($, Backbone, _, gettext, HtmlUtils, StringUtils, MoveXBlockBreadcrumbViewTemplate) {
+ 'use strict';
+
+ var MoveXBlockBreadcrumb = Backbone.View.extend({
+ el: '.breadcrumb-container',
+
+ events: {
+ 'click .parent-nav-button': 'handleBreadcrumbButtonPress'
+ },
+
+ initialize: function() {
+ this.template = HtmlUtils.template(MoveXBlockBreadcrumbViewTemplate);
+ this.listenTo(Backbone, 'move:childrenRendered', this.render);
+ },
+
+ render: function(options) {
+ HtmlUtils.setHtml(
+ this.$el,
+ this.template(options)
+ );
+ Backbone.trigger('move:breadcrumbRendered');
+ return this;
+ },
+
+ /**
+ * Event handler for breadcrumb button press.
+ *
+ * @param {Object} event
+ */
+ handleBreadcrumbButtonPress: function(event) {
+ Backbone.trigger(
+ 'move:breadcrumbButtonPressed',
+ $(event.target).data('parentIndex')
+ );
+ }
+ });
+
+ return MoveXBlockBreadcrumb;
+});
diff --git a/cms/static/js/views/move_xblock_list.js b/cms/static/js/views/move_xblock_list.js
new file mode 100644
index 000000000000..a9362339972b
--- /dev/null
+++ b/cms/static/js/views/move_xblock_list.js
@@ -0,0 +1,203 @@
+/**
+ * XBlockListView shows list of XBlocks in a particular category(section, subsection, vertical etc).
+ */
+define([
+ 'jquery', 'backbone', 'underscore', 'gettext',
+ 'edx-ui-toolkit/js/utils/html-utils',
+ 'edx-ui-toolkit/js/utils/string-utils',
+ 'js/views/utils/xblock_utils',
+ 'text!templates/move-xblock-list.underscore'
+],
+function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBlockListViewTemplate) {
+ 'use strict';
+
+ var XBlockListView = Backbone.View.extend({
+ el: '.xblock-list-container',
+
+ // parent info of currently displayed children
+ parentInfo: {},
+ // currently displayed children XBlocks info
+ childrenInfo: {},
+ // list of visited parent XBlocks, needed for backward navigation
+ visitedAncestors: null,
+
+ // parent to child relation map
+ categoryRelationMap: {
+ course: 'section',
+ section: 'subsection',
+ subsection: 'unit',
+ unit: 'component'
+ },
+
+ categoriesText: {
+ section: gettext('Sections'),
+ subsection: gettext('Subsections'),
+ unit: gettext('Units'),
+ component: gettext('Components'),
+ group: gettext('Groups')
+ },
+
+ events: {
+ 'click .button-forward': 'renderChildren'
+ },
+
+ initialize: function(options) {
+ this.visitedAncestors = [];
+ this.template = HtmlUtils.template(MoveXBlockListViewTemplate);
+ this.sourceXBlockInfo = options.sourceXBlockInfo;
+ this.ancestorInfo = options.ancestorInfo;
+ this.listenTo(Backbone, 'move:breadcrumbButtonPressed', this.handleBreadcrumbButtonPress);
+ this.renderXBlockInfo();
+ },
+
+ render: function() {
+ HtmlUtils.setHtml(
+ this.$el,
+ this.template(
+ {
+ sourceXBlockId: this.sourceXBlockInfo.id,
+ xblocks: this.childrenInfo.children,
+ noChildText: this.getNoChildText(),
+ categoryText: this.getCategoryText(),
+ parentDisplayname: this.parentInfo.parent.get('display_name'),
+ XBlocksCategory: this.childrenInfo.category,
+ currentLocationIndex: this.getCurrentLocationIndex()
+ }
+ )
+ );
+ Backbone.trigger('move:childrenRendered', this.breadcrumbInfo());
+ Backbone.trigger('move:enableMoveOperation', this.parentInfo.parent);
+ return this;
+ },
+
+ /**
+ * Forward button press handler. This will render all the childs of an XBlock.
+ *
+ * @param {Object} event
+ */
+ renderChildren: function(event) {
+ this.renderXBlockInfo(
+ 'forward',
+ $(event.target).closest('.xblock-item').data('itemIndex')
+ );
+ },
+
+ /**
+ * Breadcrumb button press event handler. Render all the childs of an XBlock.
+ *
+ * @param {any} newParentIndex Index of a parent XBlock
+ */
+ handleBreadcrumbButtonPress: function(newParentIndex) {
+ this.renderXBlockInfo('backward', newParentIndex);
+ },
+
+ /**
+ * Render XBlocks based on `forward` or `backward` navigation.
+ *
+ * @param {any} direction `forward` or `backward`
+ * @param {any} newParentIndex Index of a parent XBlock
+ */
+ renderXBlockInfo: function(direction, newParentIndex) {
+ if (direction === undefined) {
+ this.parentInfo.parent = this.model;
+ } else if (direction === 'forward') {
+ // clicked child is the new parent
+ this.parentInfo.parent = this.childrenInfo.children[newParentIndex];
+ } else if (direction === 'backward') {
+ // new parent will be one of visitedAncestors
+ this.parentInfo.parent = this.visitedAncestors[newParentIndex];
+ // remove visited ancestors
+ this.visitedAncestors.splice(newParentIndex);
+ }
+
+ this.visitedAncestors.push(this.parentInfo.parent);
+
+ if (this.parentInfo.parent.get('child_info')) {
+ this.childrenInfo.children = this.parentInfo.parent.get('child_info').children;
+ } else {
+ this.childrenInfo.children = [];
+ }
+
+ this.setDisplayedXBlocksCategories();
+ this.render();
+ },
+
+ /**
+ * Set parent and child XBlock categories.
+ */
+ setDisplayedXBlocksCategories: function() {
+ var childCategory = 'component';
+ this.parentInfo.category = XBlockUtils.getXBlockType(this.parentInfo.parent.get('category'));
+ if (!_.contains(_.keys(this.categoryRelationMap), this.parentInfo.category)) {
+ if (this.parentInfo.category === 'split_test') {
+ childCategory = 'group'; // This is just to show groups text on group listing.
+ }
+ this.categoryRelationMap[this.parentInfo.category] = childCategory;
+ }
+ this.childrenInfo.category = this.categoryRelationMap[this.parentInfo.category];
+ },
+
+ /**
+ * Get index of source XBlock.
+ *
+ * @returns {any} Integer or undefined
+ */
+ getCurrentLocationIndex: function() {
+ var self = this,
+ currentLocationIndex;
+ _.each(self.childrenInfo.children, function(xblock, index) {
+ if (xblock.get('id') === self.sourceXBlockInfo.id) {
+ currentLocationIndex = index;
+ } else {
+ _.each(self.ancestorInfo.ancestors, function(ancestor) {
+ if (ancestor.display_name === xblock.get('display_name') && ancestor.id === xblock.get('id')) {
+ currentLocationIndex = index;
+ }
+ });
+ }
+ });
+
+ return currentLocationIndex;
+ },
+
+ /**
+ * Get category text for currently displayed children.
+ *
+ * @returns {String}
+ */
+ getCategoryText: function() {
+ return this.categoriesText[this.childrenInfo.category];
+ },
+
+ /**
+ * Get text when a parent XBlock has no children.
+ *
+ * @returns {String}
+ */
+ getNoChildText: function() {
+ return StringUtils.interpolate(
+ gettext('This {parentCategory} has no {childCategory}'),
+ {
+ parentCategory: this.parentInfo.category,
+ childCategory: this.categoriesText[this.childrenInfo.category].toLowerCase()
+ }
+ );
+ },
+
+ /**
+ * Construct breadcurmb info.
+ *
+ * @returns {Object}
+ */
+ breadcrumbInfo: function() {
+ return {
+ breadcrumbs: _.map(this.visitedAncestors, function(ancestor) {
+ return ancestor.get('category') === 'course' ?
+ gettext('Course Outline') : ancestor.get('display_name');
+ })
+ };
+ }
+ });
+
+ return XBlockListView;
+});
diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js
index 73ee99d834bd..f26723270b43 100644
--- a/cms/static/js/views/pages/container.js
+++ b/cms/static/js/views/pages/container.js
@@ -2,13 +2,14 @@
* XBlockContainerPage is used to display Studio's container page for an xblock which has children.
* This page allows the user to understand and manipulate the xblock and its children.
*/
-define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/js/components/utils/view_utils',
- 'js/views/container', 'js/views/xblock', 'js/views/components/add_xblock', 'js/views/modals/edit_xblock',
+define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page',
+ 'common/js/components/utils/view_utils', 'js/views/container', 'js/views/xblock',
+ 'js/views/components/add_xblock', 'js/views/modals/edit_xblock', 'js/views/modals/move_xblock_modal',
'js/models/xblock_info', 'js/views/xblock_string_field_editor', 'js/views/pages/container_subviews',
'js/views/unit_outline', 'js/views/utils/xblock_utils'],
- function($, _, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent,
- EditXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews, UnitOutlineView,
- XBlockUtils) {
+ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent,
+ EditXBlockModal, MoveXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews,
+ UnitOutlineView, XBlockUtils) {
'use strict';
var XBlockContainerPage = BasePage.extend({
// takes XBlockInfo as a model
@@ -17,6 +18,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
'click .edit-button': 'editXBlock',
'click .visibility-button': 'editVisibilitySettings',
'click .duplicate-button': 'duplicateXBlock',
+ 'click .move-button': 'showMoveXBlockModal',
'click .delete-button': 'deleteXBlock',
'click .new-component-button': 'scrollToNewComponentButtons'
},
@@ -80,6 +82,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
});
this.unitOutlineView.render();
}
+
+ this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved);
},
getViewParameters: function() {
@@ -191,6 +195,20 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
this.duplicateComponent(this.findXBlockElement(event.target));
},
+ showMoveXBlockModal: function(event) {
+ var xblockElement = this.findXBlockElement(event.target),
+ parentXBlockElement = xblockElement.parents('.studio-xblock-wrapper'),
+ modal = new MoveXBlockModal({
+ sourceXBlockInfo: XBlockUtils.findXBlockInfo(xblockElement, this.model),
+ sourceParentXBlockInfo: XBlockUtils.findXBlockInfo(parentXBlockElement, this.model),
+ XBlockURLRoot: this.getURLRoot(),
+ outlineURL: this.options.outlineURL
+ });
+
+ event.preventDefault();
+ modal.show();
+ },
+
deleteXBlock: function(event) {
event.preventDefault();
this.deleteComponent(this.findXBlockElement(event.target));
@@ -268,6 +286,13 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
this.model.fetch();
},
+ /*
+ After move operation is complete, updates the xblock information from server .
+ */
+ onXBlockMoved: function() {
+ this.model.fetch();
+ },
+
onNewXBlock: function(xblockElement, scrollOffset, is_duplicate, data) {
ViewUtils.setScrollOffset(xblockElement, scrollOffset);
xblockElement.data('locator', data.locator);
diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js
index c3dabf3a754d..0a0e028b23ce 100644
--- a/cms/static/js/views/pages/container_subviews.js
+++ b/cms/static/js/views/pages/container_subviews.js
@@ -2,10 +2,11 @@
* Subviews (usually small side panels) for XBlockContainerPage.
*/
define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/components/utils/view_utils',
- 'js/views/utils/xblock_utils'],
- function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils) {
- var VisibilityState = XBlockViewUtils.VisibilityState,
- disabledCss = 'is-disabled';
+ 'js/views/utils/xblock_utils', 'js/views/utils/move_xblock_utils'],
+ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils) {
+ 'use strict';
+
+ var disabledCss = 'is-disabled';
/**
* A view that refreshes the view when certain values in the XBlockInfo have changed
@@ -132,6 +133,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
return xblockInfo.save({publish: 'make_public'}, {patch: true});
}).always(function() {
xblockInfo.set('publish', null);
+ // Hide any move notification if present.
+ MoveXBlockUtils.hideMovedNotification();
}).done(function() {
xblockInfo.fetch();
});
@@ -151,6 +154,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
return xblockInfo.save({publish: 'discard_changes'}, {patch: true});
}).always(function() {
xblockInfo.set('publish', null);
+ // Hide any move notification if present.
+ MoveXBlockUtils.hideMovedNotification();
}).done(function() {
renderPage();
});
diff --git a/cms/static/js/views/utils/move_xblock_utils.js b/cms/static/js/views/utils/move_xblock_utils.js
new file mode 100644
index 000000000000..e3344c5cb801
--- /dev/null
+++ b/cms/static/js/views/utils/move_xblock_utils.js
@@ -0,0 +1,129 @@
+/**
+ * Provides utilities for move xblock.
+ */
+define([
+ 'jquery',
+ 'underscore',
+ 'backbone',
+ 'common/js/components/views/feedback',
+ 'common/js/components/views/feedback_alert',
+ 'js/views/utils/xblock_utils',
+ 'js/views/utils/move_xblock_utils',
+ 'edx-ui-toolkit/js/utils/string-utils'
+],
+function($, _, Backbone, Feedback, AlertView, XBlockViewUtils, MoveXBlockUtils, StringUtils) {
+ 'use strict';
+ var redirectLink, moveXBlock, undoMoveXBlock, showMovedNotification, hideMovedNotification;
+
+ redirectLink = function(link) {
+ window.location.href = link;
+ };
+
+ moveXBlock = function(data) {
+ XBlockViewUtils.moveXBlock(data.sourceLocator, data.targetParentLocator)
+ .done(function(response) {
+ // hide modal
+ Backbone.trigger('move:hideMoveModal');
+ // hide xblock element
+ data.sourceXBlockElement.hide();
+ showMovedNotification(
+ StringUtils.interpolate(
+ gettext('Success! "{displayName}" has been moved.'),
+ {
+ displayName: data.sourceDisplayName
+ }
+ ),
+ {
+ sourceXBlockElement: data.sourceXBlockElement,
+ sourceDisplayName: data.sourceDisplayName,
+ sourceLocator: data.sourceLocator,
+ sourceParentLocator: data.sourceParentLocator,
+ targetParentLocator: data.targetParentLocator,
+ targetIndex: response.source_index
+ }
+ );
+ Backbone.trigger('move:onXBlockMoved');
+ });
+ };
+
+ undoMoveXBlock = function(data) {
+ XBlockViewUtils.moveXBlock(data.sourceLocator, data.sourceParentLocator, data.targetIndex)
+ .done(function() {
+ // show XBlock element
+ data.sourceXBlockElement.show();
+ showMovedNotification(
+ StringUtils.interpolate(
+ gettext('Move cancelled. "{sourceDisplayName}" has been moved back to its original location.'),
+ {
+ sourceDisplayName: data.sourceDisplayName
+ }
+ )
+ );
+ Backbone.trigger('move:onXBlockMoved');
+ });
+ };
+
+ showMovedNotification = function(title, data) {
+ var movedAlertView;
+ // data is provided when we click undo move button.
+ if (data) {
+ movedAlertView = new AlertView.Confirmation({
+ title: title,
+ actions: {
+ primary: {
+ text: gettext('Undo move'),
+ class: 'action-save',
+ click: function() {
+ undoMoveXBlock(
+ {
+ sourceXBlockElement: data.sourceXBlockElement,
+ sourceDisplayName: data.sourceDisplayName,
+ sourceLocator: data.sourceLocator,
+ sourceParentLocator: data.sourceParentLocator,
+ targetIndex: data.targetIndex
+ }
+ );
+ }
+ },
+ secondary: [
+ {
+ text: gettext('Take me to the new location'),
+ class: 'action-cancel',
+ click: function() {
+ redirectLink('/container/' + data.targetParentLocator);
+ }
+ }
+ ]
+ }
+ });
+ } else {
+ movedAlertView = new AlertView.Confirmation({
+ title: title
+ });
+ }
+ movedAlertView.show();
+ // scroll to top
+ $.smoothScroll({
+ offset: 0,
+ easing: 'swing',
+ speed: 1000
+ });
+ movedAlertView.$('.wrapper').first().focus();
+ return movedAlertView;
+ };
+
+ hideMovedNotification = function() {
+ var movedAlertView = Feedback.active_alert;
+ if (movedAlertView) {
+ AlertView.prototype.hide.apply(movedAlertView);
+ }
+ };
+
+ return {
+ redirectLink: redirectLink,
+ moveXBlock: moveXBlock,
+ undoMoveXBlock: undoMoveXBlock,
+ showMovedNotification: showMovedNotification,
+ hideMovedNotification: hideMovedNotification
+ };
+});
diff --git a/cms/static/js/views/utils/xblock_utils.js b/cms/static/js/views/utils/xblock_utils.js
index 38233f8cef8e..970ad6162dd4 100644
--- a/cms/static/js/views/utils/xblock_utils.js
+++ b/cms/static/js/views/utils/xblock_utils.js
@@ -2,11 +2,12 @@
* Provides utilities for views to work with xblocks.
*/
define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_utils', 'js/utils/module',
- 'edx-ui-toolkit/js/utils/string-utils'],
- function($, _, gettext, ViewUtils, ModuleUtils, StringUtils) {
+ 'js/models/xblock_info', 'edx-ui-toolkit/js/utils/string-utils'],
+ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) {
'use strict';
var addXBlock, duplicateXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField, VisibilityState,
- getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields, getXBlockType;
+ getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields, getXBlockType, findXBlockInfo,
+ moveXBlock;
/**
* Represents the possible visibility states for an xblock:
@@ -91,6 +92,34 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util
});
};
+ /**
+ * Moves the specified xblock in a new parent xblock.
+ * @param {String} sourceLocator Locator of xblock element to be moved.
+ * @param {String} targetParentLocator Locator of the target parent xblock, moved xblock would be placed
+ * under this xblock.
+ * @param {Integer} targetIndex Intended index position of the xblock in parent xblock. If provided,
+ * xblock would be placed at the particular index in the parent xblock.
+ * @returns {jQuery promise} A promise representing the moving of the xblock.
+ */
+ moveXBlock = function(sourceLocator, targetParentLocator, targetIndex) {
+ var moveOperation = $.Deferred(),
+ operationText = targetIndex !== undefined ? gettext('Undo moving') : gettext('Moving');
+ return ViewUtils.runOperationShowingMessage(operationText,
+ function() {
+ $.patchJSON(ModuleUtils.getUpdateUrl(), {
+ move_source_locator: sourceLocator,
+ parent_locator: targetParentLocator,
+ target_index: targetIndex
+ }, function(response) {
+ moveOperation.resolve(response);
+ })
+ .fail(function() {
+ moveOperation.reject();
+ });
+ return moveOperation.promise();
+ });
+ };
+
/**
* Deletes the specified xblock.
* @param xblockInfo The model for the xblock to be deleted.
@@ -240,15 +269,44 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util
return xblockType;
};
+ findXBlockInfo = function(xblockWrapperElement, defaultXBlockInfo) {
+ var xblockInfo = defaultXBlockInfo,
+ xblockElement,
+ displayName,
+ hasChildren;
+ if (xblockWrapperElement.length > 0) {
+ xblockElement = xblockWrapperElement.find('.xblock');
+ displayName = xblockWrapperElement.find(
+ '.xblock-header .header-details .xblock-display-name'
+ ).text().trim();
+ // If not found, try looking for the old unit page style rendering.
+ // Only used now by static pages.
+ if (!displayName) {
+ displayName = xblockElement.find('.component-header').text().trim();
+ }
+ hasChildren = defaultXBlockInfo ? defaultXBlockInfo.get('has_children') : false;
+ xblockInfo = new XBlockInfo({
+ id: xblockWrapperElement.data('locator'),
+ courseKey: xblockWrapperElement.data('course-key'),
+ category: xblockElement.data('block-type'),
+ display_name: displayName,
+ has_children: hasChildren
+ });
+ }
+ return xblockInfo;
+ };
+
return {
- 'VisibilityState': VisibilityState,
- 'addXBlock': addXBlock,
+ VisibilityState: VisibilityState,
+ addXBlock: addXBlock,
+ moveXBlock: moveXBlock,
duplicateXBlock: duplicateXBlock,
- 'deleteXBlock': deleteXBlock,
- 'updateXBlockField': updateXBlockField,
- 'getXBlockVisibilityClass': getXBlockVisibilityClass,
- 'getXBlockListTypeClass': getXBlockListTypeClass,
- 'updateXBlockFields': updateXBlockFields,
- 'getXBlockType': getXBlockType
+ deleteXBlock: deleteXBlock,
+ updateXBlockField: updateXBlockField,
+ getXBlockVisibilityClass: getXBlockVisibilityClass,
+ getXBlockListTypeClass: getXBlockListTypeClass,
+ updateXBlockFields: updateXBlockFields,
+ getXBlockType: getXBlockType,
+ findXBlockInfo: findXBlockInfo
};
});
diff --git a/cms/static/sass/elements/_controls.scss b/cms/static/sass/elements/_controls.scss
index 4b18144b8a3b..00f23ae0cd77 100644
--- a/cms/static/sass/elements/_controls.scss
+++ b/cms/static/sass/elements/_controls.scss
@@ -205,7 +205,7 @@
text-shadow: 0 1px 0 $btn-lms-shadow;
background-clip: padding-box;
font-size: 0.8125em;
-
+
&:focus,
&:hover {
box-shadow: inset 0 1px 0 0 $btn-lms-shadow-hover;
@@ -214,7 +214,7 @@
background-image: -webkit-linear-gradient($btn-lms-background-hover,$btn-lms-gradient-hover);
background-image: linear-gradient($btn-lms-background-hover,$btn-lms-gradient-hover);
}
-
+
&:active {
border: 1px solid $btn-lms-border;
box-shadow: inset 0 0 8px 4px $btn-lms-shadow-active,inset 0 0 8px 4px $btn-lms-shadow-active;
@@ -336,6 +336,28 @@
&.toggle-action {
// TODO: generalize and move checkbox styling in from static-pages and assets sass
}
+
+ .btn-default.delete-button {
+ border: none;
+ }
+
+ .btn-default.edit-button {
+ font-weight: 300;
+ }
+
+ .stack-move-icon {
+ font-size: 0.52em;
+
+ @include rtl {
+ .fa-file-o {
+ @include transform(rotateY(180deg));
+ }
+
+ .fa-arrow-right {
+ @include transform(rotate(180deg));
+ }
+ }
+ }
}
}
diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss
index 4a5ec294c957..472c869a270e 100644
--- a/cms/static/sass/elements/_modal-window.scss
+++ b/cms/static/sass/elements/_modal-window.scss
@@ -285,6 +285,25 @@
// specific modal overrides
// ------------------------
+ // Move XBlock Modal
+ .modal-window.move-modal {
+ top: 10% !important;
+ }
+
+ .move-xblock-modal {
+ .modal-content {
+ padding: ($baseline/2) ($baseline/2) ($baseline*1.25) ($baseline/2);
+ }
+ .ui-loading {
+ box-shadow: none;
+ }
+
+ .modal-actions .action-move.is-disabled {
+ border: 1px solid $gray-l1 !important;
+ background: $gray-l1 !important;
+ }
+ }
+
// upload modal
.assetupload-modal {
diff --git a/cms/static/sass/partials/_variables.scss b/cms/static/sass/partials/_variables.scss
index 69912569b1e0..473fc49b5c51 100644
--- a/cms/static/sass/partials/_variables.scss
+++ b/cms/static/sass/partials/_variables.scss
@@ -278,3 +278,5 @@ $body-line-height: golden-ratio(.875em, 1);
// carried over from LMS for xmodules
$action-primary-active-bg: #1AA1DE !default; // $m-blue
$very-light-text: $white !default;
+
+$color-background-alternate: rgb(242, 248, 251) !default;
diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss
index 3c26cf1a0b50..0a10ecb9362f 100644
--- a/cms/static/sass/views/_container.scss
+++ b/cms/static/sass/views/_container.scss
@@ -331,3 +331,115 @@
}
}
}
+
+
+.move-xblock-modal {
+
+ button {
+ background: transparent;
+ border-color: transparent;
+ padding: 0;
+ border: none;
+ }
+
+ .breadcrumb-container {
+ margin-bottom: ($baseline/4);
+ border: 1px solid $btn-lms-border;
+ padding: ($baseline/2);
+ background: $color-background-alternate;
+
+ .breadcrumbs {
+
+ .bc-container {
+ @include font-size(14);
+ display: inline-block;
+
+ .breadcrumb-fa-icon {
+ padding: 0 ($baseline/4);
+
+ @include rtl {
+ @include transform(rotate(180deg));
+ }
+ }
+
+ &.last {
+ .parent-displayname {
+ @include font-size(18);
+ }
+ }
+ }
+
+ .bc-container:not(.last) {
+ button, .parent-displayname {
+ text-decoration: underline;
+ color: $ui-link-color;
+ }
+ }
+ }
+ }
+
+ .category-text {
+ @include margin-left($baseline/2);
+ @include font-size(14);
+ color: $black;
+ }
+
+ .xblock-items-container {
+ max-height: ($baseline*15);
+ overflow-y: auto;
+
+ .xblock-item {
+ & > * {
+ width: 100%;
+ color: $uxpl-blue-hover-active;
+ }
+
+ .component {
+ display: inline-block;
+ color: $black;
+ padding: ($baseline/4) ($baseline/2);
+ }
+
+ .xblock-displayname {
+ @include float(left);
+ }
+
+ .button-forward, .component {
+ border: none;
+ }
+
+ .button-forward {
+ padding: ($baseline/2);
+ .forward-sr-icon {
+ @include float(right);
+
+ @include rtl {
+ @include transform(rotate(180deg));
+ }
+ }
+
+ &:hover, &:focus {
+ background: $color-background-alternate;
+ }
+ }
+ }
+
+ .xblock-no-child-message {
+ @include text-align(center);
+ display: block;
+ padding: ($baseline*2);
+ }
+ }
+
+ .truncate {
+ max-width: 90%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .current-location {
+ @include float(left);
+ @include margin-left($baseline);
+ }
+}
diff --git a/cms/templates/component.html b/cms/templates/component.html
index b03418af1189..c42e3718984f 100644
--- a/cms/templates/component.html
+++ b/cms/templates/component.html
@@ -8,22 +8,31 @@
-
+
${_("Duplicate this component")}
-
+
+
+
+
+
+
+
+
+ ${_("Move")}
+
-
+
${_("Delete this component")}
-
+
diff --git a/cms/templates/container.html b/cms/templates/container.html
index ebabe6764e9f..a313733c3b9b 100644
--- a/cms/templates/container.html
+++ b/cms/templates/container.html
@@ -43,7 +43,8 @@
"${action | n, js_escaped_string}",
{
isUnitPage: ${is_unit_page | n, dump_js_escaped_json},
- canEdit: true
+ canEdit: true,
+ outlineURL: "${outline_url | n, js_escaped_string}"
}
);
});
diff --git a/cms/templates/js/basic-modal.underscore b/cms/templates/js/basic-modal.underscore
index 84bdb2b339bd..4273fe4f9956 100644
--- a/cms/templates/js/basic-modal.underscore
+++ b/cms/templates/js/basic-modal.underscore
@@ -5,9 +5,18 @@
diff --git a/cms/templates/js/mock/mock-container-xblock.underscore b/cms/templates/js/mock/mock-container-xblock.underscore
index 606f1ff03766..e66ca58701dc 100644
--- a/cms/templates/js/mock/mock-container-xblock.underscore
+++ b/cms/templates/js/mock/mock-container-xblock.underscore
@@ -44,16 +44,19 @@