Commit 40621b6c by Mushtaq Ali Committed by GitHub

Merge pull request #14315 from edx/mushtaq/move-component

Move a component
parents be0fad1b 7ab699bc
...@@ -129,8 +129,8 @@ def edit_component(index=0): ...@@ -129,8 +129,8 @@ def edit_component(index=0):
# Verify that the "loading" indication has been hidden. # Verify that the "loading" indication has been hidden.
world.wait_for_loading() world.wait_for_loading()
# Verify that the "edit" button is present. # Verify that the "edit" button is present.
world.wait_for(lambda _driver: world.css_visible('a.edit-button')) world.wait_for(lambda _driver: world.css_visible('.edit-button'))
world.css_click('a.edit-button', index) world.css_click('.edit-button', index)
world.wait_for_ajax_complete() world.wait_for_ajax_complete()
......
...@@ -38,7 +38,7 @@ def not_see_any_static_pages(step): ...@@ -38,7 +38,7 @@ def not_see_any_static_pages(step):
@step(u'I "(edit|delete)" the static page$') @step(u'I "(edit|delete)" the static page$')
def click_edit_or_delete(step, edit_or_delete): 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) world.css_click(button_css)
......
...@@ -283,6 +283,23 @@ def reverse_usage_url(handler_name, usage_key, kwargs=None): ...@@ -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) 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): def get_user_partition_info(xblock, schemes=None, course=None):
""" """
Retrieve user partition information for an XBlock for display in editors. Retrieve user partition information for an XBlock for display in editors.
......
...@@ -20,7 +20,7 @@ from xblock.exceptions import NoSuchHandlerError ...@@ -20,7 +20,7 @@ from xblock.exceptions import NoSuchHandlerError
from xblock.plugin import PluginMissingError from xblock.plugin import PluginMissingError
from xblock.runtime import Mixologist 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.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 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): ...@@ -165,6 +165,7 @@ def container_handler(request, usage_key_string):
'subsection': subsection, 'subsection': subsection,
'section': section, 'section': section,
'new_unit_category': 'vertical', 'new_unit_category': 'vertical',
'outline_url': '{url}?format=concise'.format(url=reverse_course_url('course_handler', course.id)),
'ancestor_xblocks': ancestor_xblocks, 'ancestor_xblocks': ancestor_xblocks,
'component_templates': component_templates, 'component_templates': component_templates,
'xblock_info': xblock_info, 'xblock_info': xblock_info,
......
...@@ -336,11 +336,16 @@ def _course_outline_json(request, course_module): ...@@ -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. 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( return create_xblock_info(
course_module, course_module,
include_child_info=True, include_child_info=True,
course_outline=True, course_outline=False if is_concise else True,
include_children_predicate=lambda xblock: not xblock.category == 'vertical', include_children_predicate=include_children_predicate,
is_concise=is_concise,
user=request.user user=request.user
) )
......
...@@ -19,6 +19,8 @@ from django.views.decorators.http import require_http_methods ...@@ -19,6 +19,8 @@ from django.views.decorators.http import require_http_methods
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import LibraryUsageLocator from opaque_keys.edx.locator import LibraryUsageLocator
from pytz import UTC from pytz import UTC
from xblock.core import XBlock
from xblock.fields import Scope from xblock.fields import Scope
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblock_django.user_service import DjangoXBlockUserService from xblock_django.user_service import DjangoXBlockUserService
...@@ -27,7 +29,7 @@ from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW ...@@ -27,7 +29,7 @@ from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
from contentstore.utils import ( from contentstore.utils import (
find_release_date_source, find_staff_lock_source, is_currently_visible_to_students, find_release_date_source, find_staff_lock_source, is_currently_visible_to_students,
ancestor_has_staff_lock, has_children_visible_to_specific_content_groups, 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, \ 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 xblock_type_display_name, get_parent_xblock, create_xblock, usage_key_with_run
...@@ -98,6 +100,7 @@ def xblock_handler(request, usage_key_string): ...@@ -98,6 +100,7 @@ def xblock_handler(request, usage_key_string):
GET GET
json: returns representation of the xblock (locator id, data, and metadata). 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=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) html: returns HTML for rendering the xblock (which includes both the "preview" view and the "editor" view)
PUT or POST or PATCH PUT or POST or PATCH
json: if xblock locator is specified, update the xblock instance. The json payload can contain 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): ...@@ -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 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 an existing xblock, or creating an entirely new one. The json playload can contain
these fields: 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 :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. :category: type of xblock, required if duplicate_source_locator is not present.
:display_name: name for new xblock, optional :display_name: name for new xblock, optional
:boilerplate: template name for populating fields, optional and only used :boilerplate: template name for populating fields, optional and only used
...@@ -149,6 +155,10 @@ def xblock_handler(request, usage_key_string): ...@@ -149,6 +155,10 @@ def xblock_handler(request, usage_key_string):
if 'graderType' in fields: if 'graderType' in fields:
# right now can't combine output of this w/ output of _get_module_info, but worthy goal # 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)) 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 # TODO: pass fields to _get_module_info and only return those
with modulestore().bulk_operations(usage_key.course_key): with modulestore().bulk_operations(usage_key.course_key):
response = _get_module_info(_get_xblock(usage_key, request.user)) response = _get_module_info(_get_xblock(usage_key, request.user))
...@@ -193,14 +203,26 @@ def xblock_handler(request, usage_key_string): ...@@ -193,14 +203,26 @@ def xblock_handler(request, usage_key_string):
request.user, request.user,
request.json.get('display_name'), 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: else:
return _create_item(request) 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: else:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Only instance creation is supported without a usage key.", 'Only instance creation is supported without a usage key.',
content_type="text/plain" content_type='text/plain'
) )
...@@ -631,10 +653,141 @@ def _create_item(request): ...@@ -631,10 +653,141 @@ def _create_item(request):
) )
return JsonResponse( 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): 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. Duplicate an existing xblock as a child of the supplied parent_usage_key.
...@@ -887,7 +1040,7 @@ def _get_gating_info(course, xblock): ...@@ -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, 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, 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. 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 ...@@ -897,6 +1050,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
There are three optional boolean parameters: There are three optional boolean parameters:
include_ancestor_info - if true, ancestor info is added to the response 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 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. 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. 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 ...@@ -933,20 +1087,22 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
graders, graders,
include_children_predicate=include_children_predicate, include_children_predicate=include_children_predicate,
user=user, user=user,
course=course course=course,
is_concise=is_concise
) )
else: else:
child_info = None child_info = None
release_date = _get_release_date(xblock, user) release_date = _get_release_date(xblock, user)
if xblock.category != 'course': if xblock.category != 'course' and not is_concise:
visibility_state = _compute_visibility_state( visibility_state = _compute_visibility_state(
xblock, child_info, is_xblock_unit and has_changes, is_self_paced(course) xblock, child_info, is_xblock_unit and has_changes, is_self_paced(course)
) )
else: else:
visibility_state = None visibility_state = None
published = modulestore().has_published_version(xblock) if not is_library_block else 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 # defining the default value 'True' for delete, duplicate, drag and add new child actions
# in xblock_actions for each xblock. # in xblock_actions for each xblock.
...@@ -969,52 +1125,62 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -969,52 +1125,62 @@ 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". # a percent value out of 100, e.g. "58%" means "58/100".
pct_sign=_('%')) pct_sign=_('%'))
user_partitions = get_user_partition_info(xblock, course=course)
xblock_info = { xblock_info = {
"id": unicode(xblock.location), 'id': unicode(xblock.location),
"display_name": xblock.display_name_with_default, 'display_name': xblock.display_name_with_default,
"category": xblock.category, 'category': xblock.category,
"edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None, 'has_children': xblock.has_children
"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),
} }
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({
'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,
})
if xblock.category == 'sequential': if xblock.category == 'sequential':
xblock_info.update({ xblock_info.update({
"hide_after_due": xblock.hide_after_due, 'hide_after_due': xblock.hide_after_due,
}) })
# update xblock_info with special exam information if the feature flag is enabled # update xblock_info with special exam information if the feature flag is enabled
if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
if xblock.category == 'course': if xblock.category == 'course':
xblock_info.update({ xblock_info.update({
"enable_proctored_exams": xblock.enable_proctored_exams, 'enable_proctored_exams': xblock.enable_proctored_exams,
"create_zendesk_tickets": xblock.create_zendesk_tickets, 'create_zendesk_tickets': xblock.create_zendesk_tickets,
"enable_timed_exams": xblock.enable_timed_exams 'enable_timed_exams': xblock.enable_timed_exams
}) })
elif xblock.category == 'sequential': elif xblock.category == 'sequential':
xblock_info.update({ xblock_info.update({
"is_proctored_exam": xblock.is_proctored_exam, 'is_proctored_exam': xblock.is_proctored_exam,
"is_practice_exam": xblock.is_practice_exam, 'is_practice_exam': xblock.is_practice_exam,
"is_time_limited": xblock.is_time_limited, 'is_time_limited': xblock.is_time_limited,
"exam_review_rules": xblock.exam_review_rules, 'exam_review_rules': xblock.exam_review_rules,
"default_time_limit_minutes": xblock.default_time_limit_minutes, 'default_time_limit_minutes': xblock.default_time_limit_minutes,
}) })
# Update with gating info # Update with gating info
...@@ -1023,30 +1189,31 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -1023,30 +1189,31 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
if xblock.category == 'sequential': if xblock.category == 'sequential':
# Entrance exam subsection should be hidden. in_entrance_exam is # Entrance exam subsection should be hidden. in_entrance_exam is
# inherited metadata, all children will have it. # inherited metadata, all children will have it.
if getattr(xblock, "in_entrance_exam", False): if getattr(xblock, 'in_entrance_exam', False):
xblock_info["is_header_visible"] = False xblock_info['is_header_visible'] = False
if data is not None: if data is not None:
xblock_info["data"] = data xblock_info['data'] = data
if metadata is not None: if metadata is not None:
xblock_info["metadata"] = metadata xblock_info['metadata'] = metadata
if include_ancestor_info: if include_ancestor_info:
xblock_info['ancestor_info'] = _create_xblock_ancestor_info(xblock, course_outline) xblock_info['ancestor_info'] = _create_xblock_ancestor_info(xblock, course_outline, include_child_info=True)
if child_info: if child_info:
xblock_info['child_info'] = child_info xblock_info['child_info'] = child_info
if visibility_state == VisibilityState.staff_only: if visibility_state == VisibilityState.staff_only:
xblock_info["ancestor_has_staff_lock"] = ancestor_has_staff_lock(xblock, parent_xblock) xblock_info['ancestor_has_staff_lock'] = ancestor_has_staff_lock(xblock, parent_xblock)
else: else:
xblock_info["ancestor_has_staff_lock"] = False xblock_info['ancestor_has_staff_lock'] = False
if course_outline: if course_outline:
if xblock_info["has_explicit_staff_lock"]: if xblock_info['has_explicit_staff_lock']:
xblock_info["staff_only_message"] = True xblock_info['staff_only_message'] = True
elif child_info and child_info["children"]: elif child_info and child_info['children']:
xblock_info["staff_only_message"] = all([child["staff_only_message"] for child in child_info["children"]]) xblock_info['staff_only_message'] = all(
[child['staff_only_message'] for child in child_info['children']]
)
else: else:
xblock_info["staff_only_message"] = False xblock_info['staff_only_message'] = False
return xblock_info return xblock_info
...@@ -1156,14 +1323,14 @@ def _compute_visibility_state(xblock, child_info, is_unit_with_changes, is_cours ...@@ -1156,14 +1323,14 @@ def _compute_visibility_state(xblock, child_info, is_unit_with_changes, is_cours
return VisibilityState.ready 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 Returns information about the ancestors of an xblock. Note that the direct parent will also return
information about all of its children. information about all of its children.
""" """
ancestors = [] 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. Collect xblock info regarding the specified xblock and its ancestors.
""" """
...@@ -1173,16 +1340,18 @@ def _create_xblock_ancestor_info(xblock, course_outline): ...@@ -1173,16 +1340,18 @@ def _create_xblock_ancestor_info(xblock, course_outline):
ancestor, ancestor,
include_child_info=include_child_info, include_child_info=include_child_info,
course_outline=course_outline, 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(ancestor), is_concise=is_concise)
collect_ancestor_info(get_parent_xblock(xblock), include_child_info=True) collect_ancestor_info(get_parent_xblock(xblock), include_child_info=include_child_info, is_concise=is_concise)
return { return {
'ancestors': ancestors '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 Returns information about the children of an xblock, as well as about the primary category
of xblock expected as children. of xblock expected as children.
...@@ -1203,6 +1372,7 @@ def _create_xblock_child_info(xblock, course_outline, graders, include_children_ ...@@ -1203,6 +1372,7 @@ def _create_xblock_child_info(xblock, course_outline, graders, include_children_
graders=graders, graders=graders,
user=user, user=user,
course=course, course=course,
is_concise=is_concise
) for child in xblock.get_children() ) for child in xblock.get_children()
] ]
return child_info return child_info
......
...@@ -274,6 +274,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): ...@@ -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': context.get('can_edit', True),
'can_edit_visibility': context.get('can_edit_visibility', True), 'can_edit_visibility': context.get('can_edit_visibility', True),
'can_add': context.get('can_add', 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) html = render_to_string('studio_xblock_wrapper.html', template_context)
frag = wrap_fragment(frag, html) frag = wrap_fragment(frag, html)
......
...@@ -12,11 +12,13 @@ from django.utils import http ...@@ -12,11 +12,13 @@ from django.utils import http
import contentstore.views.component as views import contentstore.views.component as views
from contentstore.views.tests.utils import StudioPageTestCase 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.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. Unit tests for the container page.
""" """
...@@ -128,6 +130,44 @@ class ContainerPageTestCase(StudioPageTestCase): ...@@ -128,6 +130,44 @@ class ContainerPageTestCase(StudioPageTestCase):
self.validate_preview_html(published_child_container, self.container_view) self.validate_preview_html(published_child_container, self.container_view)
self.validate_preview_html(published_child_vertical, self.reorderable_child_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): def test_draft_container_preview_html(self):
""" """
Verify that a draft xblock's container preview returns the expected HTML. Verify that a draft xblock's container preview returns the expected HTML.
......
...@@ -352,11 +352,16 @@ class TestCourseOutline(CourseTestCase): ...@@ -352,11 +352,16 @@ class TestCourseOutline(CourseTestCase):
parent_location=self.vertical.location, category="video", display_name="My Video" 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. 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 = 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') resp = self.client.get(outline_url, HTTP_ACCEPT='application/json')
json_response = json.loads(resp.content) json_response = json.loads(resp.content)
...@@ -364,8 +369,8 @@ class TestCourseOutline(CourseTestCase): ...@@ -364,8 +369,8 @@ class TestCourseOutline(CourseTestCase):
self.assertEqual(json_response['category'], 'course') self.assertEqual(json_response['category'], 'course')
self.assertEqual(json_response['id'], unicode(self.course.location)) self.assertEqual(json_response['id'], unicode(self.course.location))
self.assertEqual(json_response['display_name'], self.course.display_name) self.assertEqual(json_response['display_name'], self.course.display_name)
self.assertTrue(json_response['published']) self.assertNotEqual(json_response.get('published', False), is_concise)
self.assertIsNone(json_response['visibility_state']) self.assertIsNone(json_response.get('visibility_state'))
# Now verify the first child # Now verify the first child
children = json_response['child_info']['children'] children = json_response['child_info']['children']
...@@ -374,24 +379,25 @@ class TestCourseOutline(CourseTestCase): ...@@ -374,24 +379,25 @@ class TestCourseOutline(CourseTestCase):
self.assertEqual(first_child_response['category'], 'chapter') self.assertEqual(first_child_response['category'], 'chapter')
self.assertEqual(first_child_response['id'], unicode(self.chapter.location)) self.assertEqual(first_child_response['id'], unicode(self.chapter.location))
self.assertEqual(first_child_response['display_name'], 'Week 1') self.assertEqual(first_child_response['display_name'], 'Week 1')
self.assertTrue(json_response['published']) self.assertNotEqual(json_response.get('published', False), is_concise)
if not is_concise:
self.assertEqual(first_child_response['visibility_state'], VisibilityState.unscheduled) self.assertEqual(first_child_response['visibility_state'], VisibilityState.unscheduled)
self.assertGreater(len(first_child_response['child_info']['children']), 0) self.assertGreater(len(first_child_response['child_info']['children']), 0)
# Finally, validate the entire response for consistency # 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 Asserts that the JSON response is syntactically consistent
""" """
self.assertIsNotNone(json_response['display_name']) self.assertIsNotNone(json_response['display_name'])
self.assertIsNotNone(json_response['id']) self.assertIsNotNone(json_response['id'])
self.assertIsNotNone(json_response['category']) 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): if json_response.get('child_info', None):
for child_response in json_response['child_info']['children']: 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): def test_course_outline_initial_state(self):
course_module = modulestore().get_item(self.course.location) course_module = modulestore().get_item(self.course.location)
......
...@@ -14,13 +14,15 @@ from django.test.client import RequestFactory ...@@ -14,13 +14,15 @@ from django.test.client import RequestFactory
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from contentstore.utils import reverse_usage_url, reverse_course_url 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 openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from contentstore.views.component import ( from contentstore.views.component import (
component_handler, get_component_templates component_handler, get_component_templates
) )
from contentstore.views.item import ( 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 contentstore.tests.utils import CourseTestCase
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
...@@ -384,6 +386,59 @@ class GetItemTest(ItemTest): ...@@ -384,6 +386,59 @@ class GetItemTest(ItemTest):
]) ])
self.assertEqual(result["group_access"], {}) 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 @ddt.ddt
class DeleteItem(ItemTest): class DeleteItem(ItemTest):
...@@ -680,6 +735,416 @@ class TestDuplicateItem(ItemTest, DuplicateHelper): ...@@ -680,6 +735,416 @@ class TestDuplicateItem(ItemTest, DuplicateHelper):
verify_name(self.seq_usage_key, self.chapter_usage_key, "customized name", display_name="customized 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): class TestDuplicateItemWithAsides(ItemTest, DuplicateHelper):
""" """
Test the duplicate method for blocks with asides. Test the duplicate method for blocks with asides.
...@@ -1312,6 +1777,31 @@ class TestEditSplitModule(ItemTest): ...@@ -1312,6 +1777,31 @@ class TestEditSplitModule(ItemTest):
self.assertEqual(vertical_0.location, split_test.group_id_to_child['0']) self.assertEqual(vertical_0.location, split_test.group_id_to_child['0'])
self.assertEqual(vertical_1.location, split_test.group_id_to_child['1']) 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): def test_change_user_partition_id(self):
""" """
Test what happens when the user_partition_id is changed to a different groups Test what happens when the user_partition_id is changed to a different groups
......
...@@ -41,33 +41,48 @@ class StudioPageTestCase(CourseTestCase): ...@@ -41,33 +41,48 @@ class StudioPageTestCase(CourseTestCase):
resp_content = json.loads(resp.content) resp_content = json.loads(resp.content)
return resp_content['html'] 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. Verify that the specified xblock's preview has the expected HTML elements.
""" """
html = self.get_preview_html(xblock, view_name) html = self.get_preview_html(xblock, view_name)
self.validate_html_for_add_buttons(html, can_add) self.validate_html_for_action_button(
html,
'<div class="add-xblock-component new-component-item adding"></div>',
can_add
)
self.validate_html_for_action_button(
html,
'<span data-tooltip="Drag to reorder" class="drag-handle action"></span>',
can_reorder
)
self.validate_html_for_action_button(
html,
'<button data-tooltip="Move" class="btn-default move-button action-button">',
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,
'<button data-tooltip="Delete" class="btn-default delete-button action-button">',
can_duplicate
)
self.validate_html_for_action_button(
html,
'<button data-tooltip="Duplicate" class="btn-default duplicate-button action-button">',
can_delete
)
# Verify drag handles always appear. def validate_html_for_action_button(self, html, expected_html, can_action=True):
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>'
self.assertIn(drag_handle_html, html)
# Verify that there are no action buttons for public blocks
expected_button_html = [
'<a href="#" class="edit-button action-button">',
'<a href="#" data-tooltip="Delete" class="delete-button action-button">',
'<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button">'
]
for button_html in expected_button_html:
self.assertIn(button_html, html)
def validate_html_for_add_buttons(self, html, can_add=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 if can_action:
add_button_html = '<div class="add-xblock-component new-component-item adding"></div>' self.assertIn(expected_html, html)
if can_add:
self.assertIn(add_button_html, html)
else: else:
self.assertNotIn(add_button_html, html) self.assertNotIn(expected_html, html)
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
'common/js/components/views/feedback_notification', 'coffee/src/ajax_prefix', 'common/js/components/views/feedback_notification', 'coffee/src/ajax_prefix',
'jquery.cookie'], 'jquery.cookie'],
function(domReady, $, str, Backbone, gettext, NotificationView) { function(domReady, $, str, Backbone, gettext, NotificationView) {
var main; var main, sendJSON;
main = function() { main = function() {
AjaxPrefix.addAjaxPrefix(jQuery, function() { AjaxPrefix.addAjaxPrefix(jQuery, function() {
return $("meta[name='path_prefix']").attr('content'); return $("meta[name='path_prefix']").attr('content');
...@@ -45,20 +45,26 @@ ...@@ -45,20 +45,26 @@
}); });
return msg.show(); return msg.show();
}); });
$.postJSON = function(url, data, callback) { sendJSON = function(url, data, callback, type) { // eslint-disable-line no-param-reassign
if ($.isFunction(data)) { if ($.isFunction(data)) {
callback = data; callback = data;
data = undefined; data = undefined;
} }
return $.ajax({ return $.ajax({
url: url, url: url,
type: 'POST', type: type,
contentType: 'application/json; charset=utf-8', contentType: 'application/json; charset=utf-8',
dataType: 'json', dataType: 'json',
data: JSON.stringify(data), data: JSON.stringify(data),
success: callback 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() { return domReady(function() {
if (window.onTouchBasedDevice()) { if (window.onTouchBasedDevice()) {
return $('body').addClass('touch-based-device'); return $('body').addClass('touch-based-device');
......
...@@ -282,7 +282,9 @@ ...@@ -282,7 +282,9 @@
'js/spec/views/pages/library_users_spec', 'js/spec/views/pages/library_users_spec',
'js/spec/views/modals/base_modal_spec', 'js/spec/views/modals/base_modal_spec',
'js/spec/views/modals/edit_xblock_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/modals/validation_error_modal_spec',
'js/spec/views/move_xblock_spec',
'js/spec/views/settings/main_spec', 'js/spec/views/settings/main_spec',
'js/spec/factories/xblock_validation_spec', 'js/spec/factories/xblock_validation_spec',
'js/certificates/spec/models/certificate_spec', 'js/certificates/spec/models/certificate_spec',
......
...@@ -50,6 +50,10 @@ function(Backbone, _, str, ModuleUtils) { ...@@ -50,6 +50,10 @@ function(Backbone, _, str, ModuleUtils) {
*/ */
'published_by': null, 'published_by': null,
/** /**
* True if the xblock is a parentable xblock.
*/
has_children: null,
/**
* True if the xblock has changes. * True if the xblock has changes.
* Note: this is not always provided as a performance optimization. It is only provided for * Note: this is not always provided as a performance optimization. It is only provided for
* verticals functioning as units. * verticals functioning as units.
......
...@@ -69,7 +69,7 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'js/spec_ ...@@ -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 // 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. // 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; return requests;
}; };
...@@ -92,7 +92,7 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'js/spec_ ...@@ -92,7 +92,7 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'js/spec_
var targetElement = getComponent(targetLocator), var targetElement = getComponent(targetLocator),
targetTop = targetElement.offset().top + 1, targetTop = targetElement.offset().top + 1,
handle = getDragHandle(sourceLocator), handle = getDragHandle(sourceLocator),
handleY = handle.offset().top + (handle.height() / 2), handleY = handle.offset().top,
dy = targetTop - handleY; dy = targetTop - handleY;
handle.simulate('drag', {dy: dy}); handle.simulate('drag', {dy: dy});
}; };
......
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('<div id="page-notification"></div><div id="reader-feedback"></div>');
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");
});
});
});
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("<div id='page-alert'></div>");
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");
});
});
});
});
...@@ -20,7 +20,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp ...@@ -20,7 +20,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
mockXBlockVisibilityEditorHtml = readFixtures('mock/mock-xblock-visibility-editor.underscore'), mockXBlockVisibilityEditorHtml = readFixtures('mock/mock-xblock-visibility-editor.underscore'),
PageClass = globalPageOptions.page, PageClass = globalPageOptions.page,
pagedSpecificTests = globalPageOptions.pagedSpecificTests, pagedSpecificTests = globalPageOptions.pagedSpecificTests,
hasVisibilityEditor = globalPageOptions.hasVisibilityEditor; hasVisibilityEditor = globalPageOptions.hasVisibilityEditor,
hasMoveModal = globalPageOptions.hasMoveModal;
beforeEach(function() { beforeEach(function() {
var newDisplayName = 'New Display Name'; var newDisplayName = 'New Display Name';
...@@ -48,6 +49,9 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp ...@@ -48,6 +49,9 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
afterEach(function() { afterEach(function() {
EditHelpers.uninstallMockXBlock(); EditHelpers.uninstallMockXBlock();
if (containerPage !== undefined) {
containerPage.remove();
}
}); });
respondWithHtml = function(html) { respondWithHtml = function(html) {
...@@ -250,6 +254,19 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp ...@@ -250,6 +254,19 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
expect(visibilityButtons.length).toBe(0); 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() { describe('Editing an xmodule', function() {
...@@ -798,7 +815,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp ...@@ -798,7 +815,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
initial: 'mock/mock-container-xblock.underscore', initial: 'mock/mock-container-xblock.underscore',
addResponse: 'mock/mock-xblock.underscore', addResponse: 'mock/mock-xblock.underscore',
hasVisibilityEditor: true, hasVisibilityEditor: true,
pagedSpecificTests: false pagedSpecificTests: false,
hasMoveModal: true
} }
); );
...@@ -811,7 +829,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp ...@@ -811,7 +829,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
initial: 'mock/mock-container-paged-xblock.underscore', initial: 'mock/mock-container-paged-xblock.underscore',
addResponse: 'mock/mock-xblock-paged.underscore', addResponse: 'mock/mock-xblock-paged.underscore',
hasVisibilityEditor: false, hasVisibilityEditor: false,
pagedSpecificTests: true pagedSpecificTests: true,
hasMoveModal: false
} }
); );
}); });
...@@ -35,6 +35,9 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp ...@@ -35,6 +35,9 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
afterEach(function() { afterEach(function() {
delete window.course; delete window.course;
if (containerPage !== undefined) {
containerPage.remove();
}
}); });
defaultXBlockInfo = { defaultXBlockInfo = {
......
...@@ -16,8 +16,11 @@ ...@@ -16,8 +16,11 @@
* size of the modal. * size of the modal.
* viewSpecificClasses: A string of CSS classes to be attached to * viewSpecificClasses: A string of CSS classes to be attached to
* the modal window. * 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. * 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'], define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
function($, _, gettext, BaseView) { function($, _, gettext, BaseView) {
...@@ -36,7 +39,11 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'], ...@@ -36,7 +39,11 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
title: '', title: '',
modalWindowClass: '.modal-window', modalWindowClass: '.modal-window',
// A list of class names, separated by space. // A list of class names, separated by space.
viewSpecificClasses: '' viewSpecificClasses: '',
addPrimaryActionButton: false,
primaryActionButtonType: 'save',
primaryActionButtonTitle: gettext('Save'),
showEditorModeButtons: true
}), }),
initialize: function() { initialize: function() {
...@@ -61,6 +68,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'], ...@@ -61,6 +68,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
type: this.options.modalType, type: this.options.modalType,
size: this.options.modalSize, size: this.options.modalSize,
title: this.getTitle(), title: this.getTitle(),
modalSRTitle: this.options.modalSRTitle,
showEditorModeButtons: this.options.showEditorModeButtons,
viewSpecificClasses: this.options.viewSpecificClasses viewSpecificClasses: this.options.viewSpecificClasses
})); }));
this.addActionButtons(); this.addActionButtons();
...@@ -84,14 +93,17 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'], ...@@ -84,14 +93,17 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
return ''; return '';
}, },
show: function() { show: function(focusModal) {
var focusModalWindow = focusModal === undefined;
this.render(); this.render();
this.resize(); this.resize();
$(window).resize(_.bind(this.resize, this)); $(window).resize(_.bind(this.resize, this));
// child may want to have its own focus management
if (focusModalWindow) {
// after showing and resizing, send focus // after showing and resizing, send focus
var modal = this.$el.find(this.options.modalWindowClass); this.$el.find(this.options.modalWindowClass).focus();
modal.focus(); }
}, },
hide: function() { hide: function() {
...@@ -112,8 +124,12 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'], ...@@ -112,8 +124,12 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
* Adds the action buttons to the modal. * Adds the action buttons to the modal.
*/ */
addActionButtons: function() { addActionButtons: function() {
if (this.options.addSaveButton) { if (this.options.addPrimaryActionButton) {
this.addActionButton('save', gettext('Save'), true); this.addActionButton(
this.options.primaryActionButtonType,
this.options.primaryActionButtonTitle,
true
);
} }
this.addActionButton('cancel', gettext('Cancel')); this.addActionButton('cancel', gettext('Cancel'));
}, },
......
...@@ -25,7 +25,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -25,7 +25,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
options: $.extend({}, BaseModal.prototype.options, { options: $.extend({}, BaseModal.prototype.options, {
modalName: 'course-outline', modalName: 'course-outline',
modalType: 'edit-settings', modalType: 'edit-settings',
addSaveButton: true, addPrimaryActionButton: true,
modalSize: 'med', modalSize: 'med',
viewSpecificClasses: 'confirm', viewSpecificClasses: 'confirm',
editors: [] editors: []
......
...@@ -4,9 +4,9 @@ ...@@ -4,9 +4,9 @@
* and upon save an optional refresh function can be invoked to update the display. * 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', define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common/js/components/utils/view_utils',
'js/models/xblock_info', 'js/views/xblock_editor'], 'js/views/utils/xblock_utils', 'js/views/xblock_editor'],
function($, _, gettext, BaseModal, ViewUtils, XBlockInfo, XBlockEditorView) { function($, _, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockEditorView) {
'strict mode'; 'use strict';
var EditXBlockModal = BaseModal.extend({ var EditXBlockModal = BaseModal.extend({
events: _.extend({}, BaseModal.prototype.events, { events: _.extend({}, BaseModal.prototype.events, {
...@@ -16,11 +16,11 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common ...@@ -16,11 +16,11 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
options: $.extend({}, BaseModal.prototype.options, { options: $.extend({}, BaseModal.prototype.options, {
modalName: 'edit-xblock', modalName: 'edit-xblock',
addSaveButton: true,
view: 'studio_view', view: 'studio_view',
viewSpecificClasses: 'modal-editor confirm', viewSpecificClasses: 'modal-editor confirm',
// Translators: "title" is the name of the current component being edited. // 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() { initialize: function() {
...@@ -37,7 +37,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common ...@@ -37,7 +37,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
*/ */
edit: function(xblockElement, rootXBlockInfo, options) { edit: function(xblockElement, rootXBlockInfo, options) {
this.xblockElement = xblockElement; this.xblockElement = xblockElement;
this.xblockInfo = this.findXBlockInfo(xblockElement, rootXBlockInfo); this.xblockInfo = XBlockViewUtils.findXBlockInfo(xblockElement, rootXBlockInfo);
this.options.modalType = this.xblockInfo.get('category'); this.options.modalType = this.xblockInfo.get('category');
this.editOptions = options; this.editOptions = options;
this.render(); this.render();
...@@ -183,28 +183,6 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common ...@@ -183,28 +183,6 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
this.editorView.notifyRuntime('modal-hidden'); 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) { addModeButton: function(mode, displayName) {
var buttonPanel = this.$('.editor-modes'); var buttonPanel = this.$('.editor-modes');
buttonPanel.append(this.editorModeButtonTemplate({ buttonPanel.append(this.editorModeButtonTemplate({
......
/**
* 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;
});
/**
* 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;
});
/**
* 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;
});
...@@ -2,13 +2,14 @@ ...@@ -2,13 +2,14 @@
* XBlockContainerPage is used to display Studio's container page for an xblock which has children. * 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. * 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', define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page',
'js/views/container', 'js/views/xblock', 'js/views/components/add_xblock', 'js/views/modals/edit_xblock', '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/models/xblock_info', 'js/views/xblock_string_field_editor', 'js/views/pages/container_subviews',
'js/views/unit_outline', 'js/views/utils/xblock_utils'], 'js/views/unit_outline', 'js/views/utils/xblock_utils'],
function($, _, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent, function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent,
EditXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews, UnitOutlineView, EditXBlockModal, MoveXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews,
XBlockUtils) { UnitOutlineView, XBlockUtils) {
'use strict'; 'use strict';
var XBlockContainerPage = BasePage.extend({ var XBlockContainerPage = BasePage.extend({
// takes XBlockInfo as a model // takes XBlockInfo as a model
...@@ -17,6 +18,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j ...@@ -17,6 +18,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
'click .edit-button': 'editXBlock', 'click .edit-button': 'editXBlock',
'click .visibility-button': 'editVisibilitySettings', 'click .visibility-button': 'editVisibilitySettings',
'click .duplicate-button': 'duplicateXBlock', 'click .duplicate-button': 'duplicateXBlock',
'click .move-button': 'showMoveXBlockModal',
'click .delete-button': 'deleteXBlock', 'click .delete-button': 'deleteXBlock',
'click .new-component-button': 'scrollToNewComponentButtons' 'click .new-component-button': 'scrollToNewComponentButtons'
}, },
...@@ -80,6 +82,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j ...@@ -80,6 +82,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
}); });
this.unitOutlineView.render(); this.unitOutlineView.render();
} }
this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved);
}, },
getViewParameters: function() { getViewParameters: function() {
...@@ -191,6 +195,20 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j ...@@ -191,6 +195,20 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
this.duplicateComponent(this.findXBlockElement(event.target)); 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) { deleteXBlock: function(event) {
event.preventDefault(); event.preventDefault();
this.deleteComponent(this.findXBlockElement(event.target)); this.deleteComponent(this.findXBlockElement(event.target));
...@@ -268,6 +286,13 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j ...@@ -268,6 +286,13 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
this.model.fetch(); 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) { onNewXBlock: function(xblockElement, scrollOffset, is_duplicate, data) {
ViewUtils.setScrollOffset(xblockElement, scrollOffset); ViewUtils.setScrollOffset(xblockElement, scrollOffset);
xblockElement.data('locator', data.locator); xblockElement.data('locator', data.locator);
......
...@@ -2,10 +2,11 @@ ...@@ -2,10 +2,11 @@
* Subviews (usually small side panels) for XBlockContainerPage. * Subviews (usually small side panels) for XBlockContainerPage.
*/ */
define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/components/utils/view_utils', define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/components/utils/view_utils',
'js/views/utils/xblock_utils'], 'js/views/utils/xblock_utils', 'js/views/utils/move_xblock_utils'],
function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils) { function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils) {
var VisibilityState = XBlockViewUtils.VisibilityState, 'use strict';
disabledCss = 'is-disabled';
var disabledCss = 'is-disabled';
/** /**
* A view that refreshes the view when certain values in the XBlockInfo have changed * 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 ...@@ -132,6 +133,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
return xblockInfo.save({publish: 'make_public'}, {patch: true}); return xblockInfo.save({publish: 'make_public'}, {patch: true});
}).always(function() { }).always(function() {
xblockInfo.set('publish', null); xblockInfo.set('publish', null);
// Hide any move notification if present.
MoveXBlockUtils.hideMovedNotification();
}).done(function() { }).done(function() {
xblockInfo.fetch(); xblockInfo.fetch();
}); });
...@@ -151,6 +154,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo ...@@ -151,6 +154,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
return xblockInfo.save({publish: 'discard_changes'}, {patch: true}); return xblockInfo.save({publish: 'discard_changes'}, {patch: true});
}).always(function() { }).always(function() {
xblockInfo.set('publish', null); xblockInfo.set('publish', null);
// Hide any move notification if present.
MoveXBlockUtils.hideMovedNotification();
}).done(function() { }).done(function() {
renderPage(); renderPage();
}); });
......
/**
* 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
};
});
...@@ -2,11 +2,12 @@ ...@@ -2,11 +2,12 @@
* Provides utilities for views to work with xblocks. * Provides utilities for views to work with xblocks.
*/ */
define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_utils', 'js/utils/module', define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_utils', 'js/utils/module',
'edx-ui-toolkit/js/utils/string-utils'], 'js/models/xblock_info', 'edx-ui-toolkit/js/utils/string-utils'],
function($, _, gettext, ViewUtils, ModuleUtils, StringUtils) { function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) {
'use strict'; 'use strict';
var addXBlock, duplicateXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField, VisibilityState, 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: * Represents the possible visibility states for an xblock:
...@@ -92,6 +93,34 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util ...@@ -92,6 +93,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. * Deletes the specified xblock.
* @param xblockInfo The model for the xblock to be deleted. * @param xblockInfo The model for the xblock to be deleted.
* @param xblockType A string representing the type of the xblock to be deleted. * @param xblockType A string representing the type of the xblock to be deleted.
...@@ -240,15 +269,44 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util ...@@ -240,15 +269,44 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util
return xblockType; 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 { return {
'VisibilityState': VisibilityState, VisibilityState: VisibilityState,
'addXBlock': addXBlock, addXBlock: addXBlock,
moveXBlock: moveXBlock,
duplicateXBlock: duplicateXBlock, duplicateXBlock: duplicateXBlock,
'deleteXBlock': deleteXBlock, deleteXBlock: deleteXBlock,
'updateXBlockField': updateXBlockField, updateXBlockField: updateXBlockField,
'getXBlockVisibilityClass': getXBlockVisibilityClass, getXBlockVisibilityClass: getXBlockVisibilityClass,
'getXBlockListTypeClass': getXBlockListTypeClass, getXBlockListTypeClass: getXBlockListTypeClass,
'updateXBlockFields': updateXBlockFields, updateXBlockFields: updateXBlockFields,
'getXBlockType': getXBlockType getXBlockType: getXBlockType,
findXBlockInfo: findXBlockInfo
}; };
}); });
...@@ -336,6 +336,28 @@ ...@@ -336,6 +336,28 @@
&.toggle-action { &.toggle-action {
// TODO: generalize and move checkbox styling in from static-pages and assets sass // 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));
}
}
}
} }
} }
......
...@@ -285,6 +285,25 @@ ...@@ -285,6 +285,25 @@
// specific modal overrides // 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 // upload modal
.assetupload-modal { .assetupload-modal {
......
...@@ -278,3 +278,5 @@ $body-line-height: golden-ratio(.875em, 1); ...@@ -278,3 +278,5 @@ $body-line-height: golden-ratio(.875em, 1);
// carried over from LMS for xmodules // carried over from LMS for xmodules
$action-primary-active-bg: #1AA1DE !default; // $m-blue $action-primary-active-bg: #1AA1DE !default; // $m-blue
$very-light-text: $white !default; $very-light-text: $white !default;
$color-background-alternate: rgb(242, 248, 251) !default;
...@@ -331,3 +331,115 @@ ...@@ -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);
}
}
...@@ -8,22 +8,31 @@ ...@@ -8,22 +8,31 @@
</div> </div>
<ul class="component-actions"> <ul class="component-actions">
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"> <button class="btn-default edit-button action-button">
<span class="icon fa fa-pencil" aria-hidden="true"></span> <span class="icon fa fa-pencil" aria-hidden="true"></span>
<span class="action-button-text">${_("Edit")}</span> <span class="action-button-text">${_("Edit")}</span>
</a> </button>
</li> </li>
<li class="action-item action-duplicate"> <li class="action-item action-duplicate">
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button"> <button data-tooltip="${_("Duplicate")}" class="btn-default duplicate-button action-button">
<span class="icon fa fa-copy" aria-hidden="true"></span> <span class="icon fa fa-copy" aria-hidden="true"></span>
<span class="sr">${_("Duplicate this component")}</span> <span class="sr">${_("Duplicate this component")}</span>
</a> </button>
</li>
<li class="action-item action-move">
<button data-tooltip="${_("Move")}" class="btn-default move-button action-button">
<span class="stack-move-icon fa-stack fa-lg">
<span class="fa fa-file-o fa-stack-2x fa-fw" aria-hidden="true"></span>
<span class="fa fa-arrow-right fa-stack-1x fa-fw" aria-hidden="true"></span>
</span>
<span class="sr">${_("Move")}</span>
</button>
</li> </li>
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button"> <button data-tooltip="${_("Delete")}" class="btn-default delete-button action-button">
<span class="icon fa fa-trash-o" aria-hidden="true"></span> <span class="icon fa fa-trash-o" aria-hidden="true"></span>
<span class="sr">${_("Delete this component")}</span> <span class="sr">${_("Delete this component")}</span>
</a> </button>
</li> </li>
</ul> </ul>
</div> </div>
......
...@@ -43,7 +43,8 @@ from openedx.core.djangolib.markup import HTML, Text ...@@ -43,7 +43,8 @@ from openedx.core.djangolib.markup import HTML, Text
"${action | n, js_escaped_string}", "${action | n, js_escaped_string}",
{ {
isUnitPage: ${is_unit_page | n, dump_js_escaped_json}, isUnitPage: ${is_unit_page | n, dump_js_escaped_json},
canEdit: true canEdit: true,
outlineURL: "${outline_url | n, js_escaped_string}"
} }
); );
}); });
......
...@@ -5,9 +5,18 @@ ...@@ -5,9 +5,18 @@
<div class="modal-window <%- viewSpecificClasses %> modal-<%- size %> modal-type-<%- type %>" tabindex="-1" aria-labelledby="modal-window-title"> <div class="modal-window <%- viewSpecificClasses %> modal-<%- size %> modal-type-<%- type %>" tabindex="-1" aria-labelledby="modal-window-title">
<div class="<%- name %>-modal"> <div class="<%- name %>-modal">
<div class="modal-header"> <div class="modal-header">
<h2 id="modal-window-title" class="title modal-window-title"><%- title %></h2> <h2 id="modal-window-title" class="title modal-window-title">
<%- title %>
<% if (modalSRTitle) { %>
<span class="sr modal-sr-title">
<%- modalSRTitle %>
</span>
<% } %>
</h2>
<% if (showEditorModeButtons) { %>
<ul class="editor-modes action-list action-modes"> <ul class="editor-modes action-list action-modes">
</ul> </ul>
<% } %>
</div> </div>
<div class="modal-content"> <div class="modal-content">
</div> </div>
......
...@@ -44,16 +44,19 @@ ...@@ -44,16 +44,19 @@
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"></a> <button class="edit-button action-button"></button>
</li> </li>
<li class="action-item action-visibility"> <li class="action-item action-visibility">
<a href="#" class="visibility-button action-button"></a> <button class="visibility-button action-button"></button>
</li> </li>
<li class="action-item action-duplicate"> <li class="action-item action-duplicate">
<a href="#" class="duplicate-button action-button"></a> <button class="duplicate-button action-button"></button>
</li>
<li class="action-item action-move">
<button data-tooltip="Move" class="move-button action-button"></button>
</li> </li>
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" class="delete-button action-button"></a> <button class="delete-button action-button"></button>
</li> </li>
<li class="action-item action-drag"> <li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
...@@ -72,16 +75,19 @@ ...@@ -72,16 +75,19 @@
<div class="xblock-header-primary"> <div class="xblock-header-primary">
<ul class="actions-list"> <ul class="actions-list">
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"></a> <button class="edit-button action-button"></button>
</li> </li>
<li class="action-item action-visibility"> <li class="action-item action-visibility">
<a href="#" class="visibility-button action-button"></a> <button class="visibility-button action-button"></button>
</li> </li>
<li class="action-item action-duplicate"> <li class="action-item action-duplicate">
<a href="#" class="duplicate-button action-button"></a> <button class="duplicate-button action-button"></button>
</li>
<li class="action-item action-move">
<button data-tooltip="Move" class="move-button action-button"></button>
</li> </li>
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" class="delete-button action-button"></a> <button class="delete-button action-button"></button>
</li> </li>
<li class="action-item action-drag"> <li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
...@@ -100,16 +106,19 @@ ...@@ -100,16 +106,19 @@
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"></a> <button class="edit-button action-button"></button>
</li> </li>
<li class="action-item action-visibility"> <li class="action-item action-visibility">
<a href="#" class="visibility-button action-button"></a> <button class="visibility-button action-button"></button>
</li> </li>
<li class="action-item action-duplicate"> <li class="action-item action-duplicate">
<a href="#" class="duplicate-button action-button"></a> <button class="duplicate-button action-button"></button>
</li>
<li class="action-item action-move">
<button data-tooltip="Move" class="move-button action-button"></button>
</li> </li>
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" class="delete-button action-button"></a> <button class="delete-button action-button"></button>
</li> </li>
<li class="action-item action-drag"> <li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
...@@ -158,16 +167,19 @@ ...@@ -158,16 +167,19 @@
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"></a> <button class="edit-button action-button"></button>
</li> </li>
<li class="action-item action-visibility"> <li class="action-item action-visibility">
<a href="#" class="visibility-button action-button"></a> <button class="visibility-button action-button"></button>
</li> </li>
<li class="action-item action-duplicate"> <li class="action-item action-duplicate">
<a href="#" class="duplicate-button action-button"></a> <button class="duplicate-button action-button"></button>
</li>
<li class="action-item action-move">
<button data-tooltip="Move" class="move-button action-button"></button>
</li> </li>
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" class="delete-button action-button"></a> <button class="delete-button action-button"></button>
</li> </li>
<li class="action-item action-drag"> <li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
...@@ -186,16 +198,19 @@ ...@@ -186,16 +198,19 @@
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"></a> <button class="edit-button action-button"></button>
</li> </li>
<li class="action-item action-visibility"> <li class="action-item action-visibility">
<a href="#" class="visibility-button action-button"></a> <button class="visibility-button action-button"></button>
</li> </li>
<li class="action-item action-duplicate"> <li class="action-item action-duplicate">
<a href="#" class="duplicate-button action-button"></a> <button class="duplicate-button action-button"></button>
</li>
<li class="action-item action-move">
<button data-tooltip="Move" class="move-button action-button"></button>
</li> </li>
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" class="delete-button action-button"></a> <button class="delete-button action-button"></button>
</li> </li>
<li class="action-item action-drag"> <li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
...@@ -214,16 +229,19 @@ ...@@ -214,16 +229,19 @@
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"></a> <button class="edit-button action-button"></button>
</li> </li>
<li class="action-item action-visibility"> <li class="action-item action-visibility">
<a href="#" class="visibility-button action-button"></a> <button class="visibility-button action-button"></button>
</li> </li>
<li class="action-item action-duplicate"> <li class="action-item action-duplicate">
<a href="#" class="duplicate-button action-button"></a> <button class="duplicate-button action-button"></button>
</li>
<li class="action-item action-move">
<button data-tooltip="Move" class="move-button action-button"></button>
</li> </li>
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" class="delete-button action-button"></a> <button class="delete-button action-button"></button>
</li> </li>
<li class="action-item action-drag"> <li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
......
<nav class="breadcrumbs" aria-label="Course Outline breadcrumb">
<% _.each(breadcrumbs.slice(0, -1), function (breadcrumb, index, items) { %>
<ol class="bc-container bc-<%- index %>">
<li class="bc-container-content">
<button class="parent-nav-button" data-parent-index="<%- index %>">
<%- breadcrumb %>
</button>
<span class="fa fa-angle-right breadcrumb-fa-icon" aria-hidden="true"></span>
</li>
</ol>
<% }) %>
<ol class="bc-container bc-<%- breadcrumbs.length - 1 %> last">
<li class="bc-container-content">
<span class="parent-displayname"><%- breadcrumbs[breadcrumbs.length - 1] %></span>
</li>
</ol>
</nav>
<div class="xblock-items-category">
<span class="sr">
<%
// Translators: message will be like `Units in Homework - Question Styles`, `Subsections in Example 1 - Getting started` etc.
%>
<%- StringUtils.interpolate(
gettext("{categoryText} in {parentDisplayname}"),
{categoryText: categoryText, parentDisplayname: parentDisplayname}
)
%>
</span>
<span class="category-text" aria-hidden="true">
<%- categoryText %>:
</span>
</div>
<ul class="xblock-items-container" data-items-category="<%- XBlocksCategory %>">
<% for (var i = 0; i < xblocks.length; i++) {
var xblock = xblocks[i];
%>
<li class="xblock-item" data-item-index="<%- i %>">
<% if (sourceXBlockId !== xblock.id && (xblock.get('child_info') || XBlocksCategory !== 'component')) { %>
<button class="button-forward" >
<span class="xblock-displayname truncate">
<%- xblock.get('display_name') %>
</span>
<% if(currentLocationIndex === i) { %>
<span class="current-location">
(<%- gettext('Current location') %>)
</span>
<% } %>
<span class="icon fa fa-arrow-right forward-sr-icon" aria-hidden="true"></span>
<span class="sr forward-sr-text"><%- gettext("View child items") %></span>
</button>
<% } else { %>
<span class="component">
<span class="xblock-displayname truncate">
<%- xblock.get('display_name') %>
</span>
<% if(currentLocationIndex === i) { %>
<span class="current-location">
(<%- gettext('Currently selected') %>)
</span>
<% } %>
</span>
<% } %>
</li>
<% } %>
<% if(xblocks.length === 0) { %>
<span class="xblock-no-child-message">
<%- noChildText %>
</span>
<% } %>
</ul>
<div class="ui-loading">
<p>
<span class="spin">
<span class="icon fa fa-refresh" aria-hidden="true"></span>
</span>
<span class="copy"><%- gettext('Loading') %></span>
</p>
</div>
<div class='breadcrumb-container is-hidden'></div>
<div class='xblock-list-container'></div>
...@@ -69,35 +69,46 @@ messages = xblock.validate().to_json() ...@@ -69,35 +69,46 @@ messages = xblock.validate().to_json()
% if can_edit: % if can_edit:
% if not show_inline: % if not show_inline:
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"> <button class="btn-default edit-button action-button">
<span class="icon fa fa-pencil" aria-hidden="true"></span> <span class="icon fa fa-pencil" aria-hidden="true"></span>
<span class="action-button-text">${_("Edit")}</span> <span class="action-button-text">${_("Edit")}</span>
</a> </button>
</li> </li>
% if can_edit_visibility: % if can_edit_visibility:
<li class="action-item action-visibility"> <li class="action-item action-visibility">
<a href="#" data-tooltip="${_("Visibility Settings")}" class="visibility-button action-button"> <button data-tooltip="${_("Visibility Settings")}" class="btn-default visibility-button action-button">
<span class="icon fa fa-eye" aria-hidden="true"></span> <span class="icon fa fa-eye" aria-hidden="true"></span>
<span class="sr">${_("Visibility")}</span> <span class="sr">${_("Visibility")}</span>
</a> </button>
</li> </li>
% endif % endif
% if can_add: % if can_add:
<li class="action-item action-duplicate"> <li class="action-item action-duplicate">
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button"> <button data-tooltip="${_("Duplicate")}" class="btn-default duplicate-button action-button">
<span class="icon fa fa-copy" aria-hidden="true"></span> <span class="icon fa fa-copy" aria-hidden="true"></span>
<span class="sr">${_("Duplicate")}</span> <span class="sr">${_("Duplicate")}</span>
</a> </button>
</li>
% endif
% if can_move:
<li class="action-item action-move">
<button data-tooltip="${_("Move")}" class="btn-default move-button action-button">
<span class="stack-move-icon fa-stack fa-lg ">
<span class="fa fa-file-o fa-stack-2x fa-fw" aria-hidden="true"></span>
<span class="fa fa-arrow-right fa-stack-1x fa-fw" aria-hidden="true"></span>
</span>
<span class="sr">${_("Move")}</span>
</button>
</li> </li>
% endif % endif
% endif % endif
% if can_add: % if can_add:
<!-- If we can add, we can delete. --> <!-- If we can add, we can delete. -->
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button"> <button data-tooltip="${_("Delete")}" class="btn-default delete-button action-button">
<span class="icon fa fa-trash-o" aria-hidden="true"></span> <span class="icon fa fa-trash-o" aria-hidden="true"></span>
<span class="sr">${_("Delete")}</span> <span class="sr">${_("Delete")}</span>
</a> </button>
</li> </li>
% endif % endif
% if is_reorderable: % if is_reorderable:
......
...@@ -338,6 +338,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): ...@@ -338,6 +338,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
'display_name': self.display_name or self.url_name, 'display_name': self.display_name or self.url_name,
})) }))
context['can_edit_visibility'] = False context['can_edit_visibility'] = False
context['can_move'] = False
self.render_children(context, fragment, can_reorder=False, can_add=False) self.render_children(context, fragment, can_reorder=False, can_add=False)
# else: When shown on a unit page, don't show any sort of preview - # else: When shown on a unit page, don't show any sort of preview -
# just the status of this block in the validation area. # just the status of this block in the validation area.
......
...@@ -80,6 +80,7 @@ class LibraryRoot(XBlock): ...@@ -80,6 +80,7 @@ class LibraryRoot(XBlock):
children_to_show = self.children[item_start:item_end] # pylint: disable=no-member children_to_show = self.children[item_start:item_end] # pylint: disable=no-member
force_render = context.get('force_render', None) force_render = context.get('force_render', None)
context['can_move'] = False
for child_key in children_to_show: for child_key in children_to_show:
# Children must have a separate context from the library itself. Make a copy. # Children must have a separate context from the library itself. Make a copy.
......
...@@ -78,16 +78,17 @@ ...@@ -78,16 +78,17 @@
return this; return this;
}, },
inFocus: function() { inFocus: function(wrapperElementSelector) {
var wrapper = wrapperElementSelector || '.wrapper',
tabbables;
this.options.outFocusElement = this.options.outFocusElement || document.activeElement; this.options.outFocusElement = this.options.outFocusElement || document.activeElement;
// Set focus to the container. // Set focus to the container.
this.$('.wrapper').first().focus(); this.$(wrapper).first().focus();
// Make tabs within the prompt loop rather than setting focus // Make tabs within the prompt loop rather than setting focus
// back to the main content of the page. // back to the main content of the page.
var tabbables = this.$(tabbable_elements.join()); tabbables = this.$(tabbable_elements.join());
tabbables.on('keydown', function(event) { tabbables.on('keydown', function(event) {
// On tab backward from the first tabbable item in the prompt // On tab backward from the first tabbable item in the prompt
if (event.which === 9 && event.shiftKey && event.target === tabbables.first()[0]) { if (event.which === 9 && event.shiftKey && event.target === tabbables.first()[0]) {
......
...@@ -63,11 +63,16 @@ class ContainerPage(PageObject, HelpMixin): ...@@ -63,11 +63,16 @@ class ContainerPage(PageObject, HelpMixin):
is_done = num_wrappers == (num_initialized_xblocks + num_failed_xblocks) is_done = num_wrappers == (num_initialized_xblocks + num_failed_xblocks)
return (is_done, is_done) return (is_done, is_done)
def _loading_spinner_hidden():
""" promise function to check loading spinner state """
is_spinner_hidden = self.q(css='div.ui-loading.is-hidden').present
return is_spinner_hidden, is_spinner_hidden
# First make sure that an element with the view-container class is present on the page, # First make sure that an element with the view-container class is present on the page,
# and then wait for the loading spinner to go away and all the xblocks to be initialized. # and then wait for the loading spinner to go away and all the xblocks to be initialized.
return ( return (
self.q(css='body.view-container').present and self.q(css='body.view-container').present and
self.q(css='div.ui-loading.is-hidden').present and Promise(_loading_spinner_hidden, 'loading spinner is hidden.').fulfill() and
Promise(_is_finished_loading, 'Finished rendering the xblock wrappers.').fulfill() Promise(_is_finished_loading, 'Finished rendering the xblock wrappers.').fulfill()
) )
...@@ -102,6 +107,13 @@ class ContainerPage(PageObject, HelpMixin): ...@@ -102,6 +107,13 @@ class ContainerPage(PageObject, HelpMixin):
return self._get_xblocks(".is-active ") return self._get_xblocks(".is-active ")
@property @property
def displayed_children(self):
"""
Return a list of displayed xblocks loaded on the container page.
"""
return self._get_xblocks()[0].children
@property
def publish_title(self): def publish_title(self):
""" """
Returns the title as displayed on the publishing sidebar component. Returns the title as displayed on the publishing sidebar component.
...@@ -217,6 +229,18 @@ class ContainerPage(PageObject, HelpMixin): ...@@ -217,6 +229,18 @@ class ContainerPage(PageObject, HelpMixin):
self.q(css='.button-view').first.click() self.q(css='.button-view').first.click()
self._switch_to_lms() self._switch_to_lms()
def verify_publish_title(self, expected_title):
"""
Waits for the publish title to change to the expected value.
"""
def wait_for_title_change():
"""
Promise function to check publish title.
"""
return (self.publish_title == expected_title, self.publish_title)
Promise(wait_for_title_change, "Publish title incorrect. Found '" + self.publish_title + "'").fulfill()
def preview(self): def preview(self):
""" """
Clicks "Preview", which will open the draft version of the unit page in the LMS. Clicks "Preview", which will open the draft version of the unit page in the LMS.
...@@ -243,7 +267,7 @@ class ContainerPage(PageObject, HelpMixin): ...@@ -243,7 +267,7 @@ class ContainerPage(PageObject, HelpMixin):
""" """
Duplicate the item with index source_index (based on vertical placement in page). Duplicate the item with index source_index (based on vertical placement in page).
""" """
click_css(self, 'a.duplicate-button', source_index) click_css(self, '.duplicate-button', source_index)
def delete(self, source_index): def delete(self, source_index):
""" """
...@@ -252,7 +276,7 @@ class ContainerPage(PageObject, HelpMixin): ...@@ -252,7 +276,7 @@ class ContainerPage(PageObject, HelpMixin):
The index of the first item is 0. The index of the first item is 0.
""" """
# Click the delete button # Click the delete button
click_css(self, 'a.delete-button', source_index, require_notification=False) click_css(self, '.delete-button', source_index, require_notification=False)
# Click the confirmation dialog button # Click the confirmation dialog button
confirm_prompt(self) confirm_prompt(self)
...@@ -262,6 +286,31 @@ class ContainerPage(PageObject, HelpMixin): ...@@ -262,6 +286,31 @@ class ContainerPage(PageObject, HelpMixin):
""" """
return _click_edit(self, '.edit-button', '.xblock-studio_view') return _click_edit(self, '.edit-button', '.xblock-studio_view')
def verify_confirmation_message(self, message, verify_hidden=False):
"""
Verify for confirmation message is present or hidden.
"""
def _verify_message():
""" promise function to check confirmation message state """
text = self.q(css='#page-alert .alert.confirmation #alert-confirmation-title').text
return text and message not in text[0] if verify_hidden else text and message in text[0]
self.wait_for(_verify_message, description='confirmation message {status}'.format(
status='hidden' if verify_hidden else 'present'
))
def click_undo_move_link(self):
"""
Click undo move link.
"""
click_css(self, '#page-alert .alert.confirmation .nav-actions .action-primary')
def click_take_me_there_link(self):
"""
Click take me there link.
"""
click_css(self, '#page-alert .alert.confirmation .nav-actions .action-secondary', require_notification=False)
def add_missing_groups(self): def add_missing_groups(self):
""" """
Click the "add missing groups" link. Click the "add missing groups" link.
...@@ -382,7 +431,7 @@ class XBlockWrapper(PageObject): ...@@ -382,7 +431,7 @@ class XBlockWrapper(PageObject):
""" """
Will return any first-generation descendant xblocks of this xblock. Will return any first-generation descendant xblocks of this xblock.
""" """
descendants = self.q(css=self._bounded_selector(self.BODY_SELECTOR)).map( descendants = self.q(css=self._bounded_selector(self.BODY_SELECTOR)).filter(lambda el: el.is_displayed()).map(
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
# Now remove any non-direct descendants. # Now remove any non-direct descendants.
...@@ -451,14 +500,14 @@ class XBlockWrapper(PageObject): ...@@ -451,14 +500,14 @@ class XBlockWrapper(PageObject):
""" """
Returns true if this xblock has a 'duplicate' button Returns true if this xblock has a 'duplicate' button
""" """
return self.q(css=self._bounded_selector('a.duplicate-button')) return self.q(css=self._bounded_selector('.duplicate-button'))
@property @property
def has_delete_button(self): def has_delete_button(self):
""" """
Returns true if this xblock has a 'delete' button Returns true if this xblock has a 'delete' button
""" """
return self.q(css=self._bounded_selector('a.delete-button')) return self.q(css=self._bounded_selector('.delete-button'))
@property @property
def has_edit_visibility_button(self): def has_edit_visibility_button(self):
...@@ -468,6 +517,13 @@ class XBlockWrapper(PageObject): ...@@ -468,6 +517,13 @@ class XBlockWrapper(PageObject):
""" """
return self.q(css=self._bounded_selector('.visibility-button')).is_present() return self.q(css=self._bounded_selector('.visibility-button')).is_present()
@property
def has_move_modal_button(self):
"""
Returns True if this xblock has move modal button else False
"""
return self.q(css=self._bounded_selector('.move-button')).is_present()
def go_to_container(self): def go_to_container(self):
""" """
Open the container page linked to by this xblock, and return Open the container page linked to by this xblock, and return
...@@ -505,6 +561,15 @@ class XBlockWrapper(PageObject): ...@@ -505,6 +561,15 @@ class XBlockWrapper(PageObject):
""" """
self._click_button('settings_tab') self._click_button('settings_tab')
def open_move_modal(self):
"""
Opens the move modal.
"""
click_css(self, '.move-button', require_notification=False)
self.wait_for(
lambda: self.q(css='.modal-window.move-modal').visible, description='move modal is visible'
)
def set_field_val(self, field_display_name, field_value): def set_field_val(self, field_display_name, field_value):
""" """
If editing, set the value of a field. If editing, set the value of a field.
......
"""
Move XBlock Modal Page Object
"""
from bok_choy.page_object import PageObject
from common.test.acceptance.pages.common.utils import click_css
class MoveModalView(PageObject):
"""
A base class for move xblock
"""
def __init__(self, browser):
"""
Arguments:
browser (selenium.webdriver): The Selenium-controlled browser that this page is loaded in.
"""
super(MoveModalView, self).__init__(browser)
def is_browser_on_page(self):
return self.q(css='.modal-window.move-modal').present
def url(self):
"""
Returns None because this is not directly accessible via URL.
"""
return None
def save(self):
"""
Clicks save button.
"""
click_css(self, 'a.action-save')
def cancel(self):
"""
Clicks cancel button.
"""
click_css(self, 'a.action-cancel', require_notification=False)
def click_forward_button(self, source_index):
"""
Click forward button at specified `source_index`.
"""
css = '.move-modal .xblock-items-container .xblock-item'
self.q(css='.button-forward').nth(source_index).click()
self.wait_for(
lambda: len(self.q(css=css).results) > 0, description='children are visible'
)
def click_move_button(self):
"""
Click move button.
"""
self.q(css='.modal-actions .action-move').first.click()
@property
def is_move_button_enabled(self):
"""
Returns True if move button on modal is enabled else False.
"""
return not self.q(css='.modal-actions .action-move.is-disabled').present
@property
def children_category(self):
"""
Get displayed children category.
"""
return self.q(css='.xblock-items-container').attrs('data-items-category')[0]
def navigate_to_category(self, category, navigation_options):
"""
Navigates to specifec `category` for a specified `source_index`.
"""
child_category = self.children_category
while child_category != category:
self.click_forward_button(navigation_options[child_category])
child_category = self.children_category
...@@ -10,13 +10,14 @@ from common.test.acceptance.fixtures.course import XBlockFixtureDesc ...@@ -10,13 +10,14 @@ from common.test.acceptance.fixtures.course import XBlockFixtureDesc
from common.test.acceptance.pages.studio.component_editor import ComponentEditorView, ComponentVisibilityEditorView from common.test.acceptance.pages.studio.component_editor import ComponentEditorView, ComponentVisibilityEditorView
from common.test.acceptance.pages.studio.container import ContainerPage from common.test.acceptance.pages.studio.container import ContainerPage
from common.test.acceptance.pages.studio.html_component_editor import HtmlComponentEditorView from common.test.acceptance.pages.studio.html_component_editor import HtmlComponentEditorView
from common.test.acceptance.pages.studio.move_xblock import MoveModalView
from common.test.acceptance.pages.studio.utils import add_discussion, drag from common.test.acceptance.pages.studio.utils import add_discussion, drag
from common.test.acceptance.pages.lms.courseware import CoursewarePage from common.test.acceptance.pages.lms.courseware import CoursewarePage
from common.test.acceptance.pages.lms.staff_view import StaffPage from common.test.acceptance.pages.lms.staff_view import StaffPage
from common.test.acceptance.tests.helpers import create_user_partition_json from common.test.acceptance.tests.helpers import create_user_partition_json
import datetime import datetime
from bok_choy.promise import Promise, EmptyPromise import ddt
from base_studio_test import ContainerBase from base_studio_test import ContainerBase
from xmodule.partitions.partitions import Group from xmodule.partitions.partitions import Group
...@@ -663,7 +664,7 @@ class UnitPublishingTest(ContainerBase): ...@@ -663,7 +664,7 @@ class UnitPublishingTest(ContainerBase):
And the last saved text contains "Last published" And the last saved text contains "Last published"
""" """
unit = self.go_to_unit_page() unit = self.go_to_unit_page()
self._verify_publish_title(unit, self.PUBLISHED_LIVE_STATUS) unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
# Start date set in course fixture to 1970. # Start date set in course fixture to 1970.
self._verify_release_date_info( self._verify_release_date_info(
unit, self.RELEASE_TITLE_RELEASED, 'Jan 01, 1970 at 00:00 UTC\nwith Section "Test Section"' unit, self.RELEASE_TITLE_RELEASED, 'Jan 01, 1970 at 00:00 UTC\nwith Section "Test Section"'
...@@ -674,11 +675,11 @@ class UnitPublishingTest(ContainerBase): ...@@ -674,11 +675,11 @@ class UnitPublishingTest(ContainerBase):
# Add a component to the page so it will have unpublished changes. # Add a component to the page so it will have unpublished changes.
add_discussion(unit) add_discussion(unit)
self._verify_publish_title(unit, self.DRAFT_STATUS) unit.verify_publish_title(self.DRAFT_STATUS)
self._verify_last_published_and_saved(unit, self.LAST_PUBLISHED, self.LAST_SAVED) self._verify_last_published_and_saved(unit, self.LAST_PUBLISHED, self.LAST_SAVED)
unit.publish_action.click() unit.publish_action.click()
unit.wait_for_ajax() unit.wait_for_ajax()
self._verify_publish_title(unit, self.PUBLISHED_LIVE_STATUS) unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
self._verify_last_published_and_saved(unit, self.LAST_PUBLISHED, self.LAST_PUBLISHED) self._verify_last_published_and_saved(unit, self.LAST_PUBLISHED, self.LAST_PUBLISHED)
def test_discard_changes(self): def test_discard_changes(self):
...@@ -695,9 +696,9 @@ class UnitPublishingTest(ContainerBase): ...@@ -695,9 +696,9 @@ class UnitPublishingTest(ContainerBase):
""" """
unit = self.go_to_unit_page() unit = self.go_to_unit_page()
add_discussion(unit) add_discussion(unit)
self._verify_publish_title(unit, self.DRAFT_STATUS) unit.verify_publish_title(self.DRAFT_STATUS)
unit.discard_changes() unit.discard_changes()
self._verify_publish_title(unit, self.PUBLISHED_LIVE_STATUS) unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
def test_view_live_no_changes(self): def test_view_live_no_changes(self):
""" """
...@@ -756,7 +757,7 @@ class UnitPublishingTest(ContainerBase): ...@@ -756,7 +757,7 @@ class UnitPublishingTest(ContainerBase):
Then I see the content in the unit Then I see the content in the unit
""" """
unit = self.go_to_unit_page("Unlocked Section", "Unlocked Subsection", "Unlocked Unit") unit = self.go_to_unit_page("Unlocked Section", "Unlocked Subsection", "Unlocked Unit")
self._verify_publish_title(unit, self.PUBLISHED_LIVE_STATUS) unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
self.assertTrue(unit.currently_visible_to_students) self.assertTrue(unit.currently_visible_to_students)
self._verify_release_date_info( self._verify_release_date_info(
unit, self.RELEASE_TITLE_RELEASED, self.past_start_date_text + '\n' + 'with Section "Unlocked Section"' unit, self.RELEASE_TITLE_RELEASED, self.past_start_date_text + '\n' + 'with Section "Unlocked Section"'
...@@ -782,7 +783,7 @@ class UnitPublishingTest(ContainerBase): ...@@ -782,7 +783,7 @@ class UnitPublishingTest(ContainerBase):
self.assertTrue(checked) self.assertTrue(checked)
self.assertFalse(unit.currently_visible_to_students) self.assertFalse(unit.currently_visible_to_students)
self.assertFalse(unit.shows_inherited_staff_lock()) self.assertFalse(unit.shows_inherited_staff_lock())
self._verify_publish_title(unit, self.LOCKED_STATUS) unit.verify_publish_title(self.LOCKED_STATUS)
self._view_published_version(unit) self._view_published_version(unit)
# Will initially be in staff view, locked component should be visible. # Will initially be in staff view, locked component should be visible.
self._verify_components_visible(['problem']) self._verify_components_visible(['problem'])
...@@ -801,7 +802,7 @@ class UnitPublishingTest(ContainerBase): ...@@ -801,7 +802,7 @@ class UnitPublishingTest(ContainerBase):
Then I do not see any content in the unit Then I do not see any content in the unit
""" """
unit = self.go_to_unit_page("Section With Locked Unit", "Subsection With Locked Unit", "Locked Unit") unit = self.go_to_unit_page("Section With Locked Unit", "Subsection With Locked Unit", "Locked Unit")
self._verify_publish_title(unit, self.LOCKED_STATUS) unit.verify_publish_title(self.LOCKED_STATUS)
self.assertFalse(unit.currently_visible_to_students) self.assertFalse(unit.currently_visible_to_students)
self._verify_release_date_info( self._verify_release_date_info(
unit, self.RELEASE_TITLE_RELEASE, unit, self.RELEASE_TITLE_RELEASE,
...@@ -825,7 +826,7 @@ class UnitPublishingTest(ContainerBase): ...@@ -825,7 +826,7 @@ class UnitPublishingTest(ContainerBase):
unit = self.go_to_unit_page("Section With Locked Unit", "Subsection With Locked Unit", "Locked Unit") unit = self.go_to_unit_page("Section With Locked Unit", "Subsection With Locked Unit", "Locked Unit")
checked = unit.toggle_staff_lock() checked = unit.toggle_staff_lock()
self.assertFalse(checked) self.assertFalse(checked)
self._verify_publish_title(unit, self.PUBLISHED_LIVE_STATUS) unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
self.assertTrue(unit.currently_visible_to_students) self.assertTrue(unit.currently_visible_to_students)
self._view_published_version(unit) self._view_published_version(unit)
# Will initially be in staff view, components always visible. # Will initially be in staff view, components always visible.
...@@ -893,10 +894,10 @@ class UnitPublishingTest(ContainerBase): ...@@ -893,10 +894,10 @@ class UnitPublishingTest(ContainerBase):
component.edit() component.edit()
HtmlComponentEditorView(self.browser, component.locator).set_content_and_save(modified_content) HtmlComponentEditorView(self.browser, component.locator).set_content_and_save(modified_content)
self.assertEqual(component.student_content, modified_content) self.assertEqual(component.student_content, modified_content)
self._verify_publish_title(unit, self.DRAFT_STATUS) unit.verify_publish_title(self.DRAFT_STATUS)
unit.publish_action.click() unit.publish_action.click()
unit.wait_for_ajax() unit.wait_for_ajax()
self._verify_publish_title(unit, self.PUBLISHED_LIVE_STATUS) unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
self._view_published_version(unit) self._view_published_version(unit)
self.assertIn(modified_content, self.courseware.xblock_component_html_content(0)) self.assertIn(modified_content, self.courseware.xblock_component_html_content(0))
...@@ -916,10 +917,10 @@ class UnitPublishingTest(ContainerBase): ...@@ -916,10 +917,10 @@ class UnitPublishingTest(ContainerBase):
component.edit() component.edit()
HtmlComponentEditorView(self.browser, component.locator).set_content_and_cancel("modified content") HtmlComponentEditorView(self.browser, component.locator).set_content_and_cancel("modified content")
self.assertEqual(component.student_content, "Body of HTML Unit.") self.assertEqual(component.student_content, "Body of HTML Unit.")
self._verify_publish_title(unit, self.PUBLISHED_LIVE_STATUS) unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
self.browser.refresh() self.browser.refresh()
unit.wait_for_page() unit.wait_for_page()
self._verify_publish_title(unit, self.PUBLISHED_LIVE_STATUS) unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
def test_delete_child_in_published_unit(self): def test_delete_child_in_published_unit(self):
""" """
...@@ -935,10 +936,10 @@ class UnitPublishingTest(ContainerBase): ...@@ -935,10 +936,10 @@ class UnitPublishingTest(ContainerBase):
""" """
unit = self.go_to_unit_page() unit = self.go_to_unit_page()
unit.delete(0) unit.delete(0)
self._verify_publish_title(unit, self.DRAFT_STATUS) unit.verify_publish_title(self.DRAFT_STATUS)
unit.publish_action.click() unit.publish_action.click()
unit.wait_for_ajax() unit.wait_for_ajax()
self._verify_publish_title(unit, self.PUBLISHED_LIVE_STATUS) unit.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
self._view_published_version(unit) self._view_published_version(unit)
self.assertEqual(0, self.courseware.num_xblock_components) self.assertEqual(0, self.courseware.num_xblock_components)
...@@ -954,12 +955,12 @@ class UnitPublishingTest(ContainerBase): ...@@ -954,12 +955,12 @@ class UnitPublishingTest(ContainerBase):
Then the title in the Publish information box is "Published (not yet released)" Then the title in the Publish information box is "Published (not yet released)"
""" """
unit = self.go_to_unit_page('Unreleased Section', 'Unreleased Subsection', 'Unreleased Unit') unit = self.go_to_unit_page('Unreleased Section', 'Unreleased Subsection', 'Unreleased Unit')
self._verify_publish_title(unit, self.PUBLISHED_STATUS) unit.verify_publish_title(self.PUBLISHED_STATUS)
add_discussion(unit) add_discussion(unit)
self._verify_publish_title(unit, self.DRAFT_STATUS) unit.verify_publish_title(self.DRAFT_STATUS)
unit.publish_action.click() unit.publish_action.click()
unit.wait_for_ajax() unit.wait_for_ajax()
self._verify_publish_title(unit, self.PUBLISHED_STATUS) unit.verify_publish_title(self.PUBLISHED_STATUS)
def _view_published_version(self, unit): def _view_published_version(self, unit):
""" """
...@@ -1006,15 +1007,6 @@ class UnitPublishingTest(ContainerBase): ...@@ -1006,15 +1007,6 @@ class UnitPublishingTest(ContainerBase):
self.assertEqual(expected_title, unit.release_title) self.assertEqual(expected_title, unit.release_title)
self.assertEqual(expected_date, unit.release_date) self.assertEqual(expected_date, unit.release_date)
def _verify_publish_title(self, unit, expected_title):
"""
Waits for the publish title to change to the expected value.
"""
def wait_for_title_change():
return (unit.publish_title == expected_title, unit.publish_title)
Promise(wait_for_title_change, "Publish title incorrect. Found '" + unit.publish_title + "'").fulfill()
def _verify_last_published_and_saved(self, unit, expected_published_prefix, expected_saved_prefix): def _verify_last_published_and_saved(self, unit, expected_published_prefix, expected_saved_prefix):
""" """
Verifies that last published and last saved messages respectively contain the given strings. Verifies that last published and last saved messages respectively contain the given strings.
...@@ -1136,3 +1128,305 @@ class ProblemCategoryTabsTest(ContainerBase): ...@@ -1136,3 +1128,305 @@ class ProblemCategoryTabsTest(ContainerBase):
"Text Input with Hints and Feedback", "Text Input with Hints and Feedback",
] ]
self.assertEqual(page.get_category_tab_components('problem', 1), expected_components) self.assertEqual(page.get_category_tab_components('problem', 1), expected_components)
@attr(shard=1)
@ddt.ddt
class MoveComponentTest(ContainerBase):
"""
Tests of moving an XBlock to another XBlock.
"""
PUBLISHED_LIVE_STATUS = "Publishing Status\nPublished and Live"
DRAFT_STATUS = "Publishing Status\nDraft (Unpublished changes)"
def setUp(self, is_staff=True):
super(MoveComponentTest, self).setUp(is_staff=is_staff)
self.container = ContainerPage(self.browser, None)
self.move_modal_view = MoveModalView(self.browser)
self.navigation_options = {
'section': 0,
'subsection': 0,
'unit': 1,
}
self.source_component_display_name = 'HTML 11'
self.source_xblock_category = 'component'
self.message_move = 'Success! "{display_name}" has been moved.'
self.message_undo = 'Move cancelled. "{display_name}" has been moved back to its original location.'
def populate_course_fixture(self, course_fixture):
"""
Sets up a course structure.
"""
# pylint: disable=attribute-defined-outside-init
self.unit_page1 = XBlockFixtureDesc('vertical', 'Test Unit 1').add_children(
XBlockFixtureDesc('html', 'HTML 11'),
XBlockFixtureDesc('html', 'HTML 12')
)
self.unit_page2 = XBlockFixtureDesc('vertical', 'Test Unit 2').add_children(
XBlockFixtureDesc('html', 'HTML 21'),
XBlockFixtureDesc('html', 'HTML 22')
)
course_fixture.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
self.unit_page1,
self.unit_page2
)
)
)
def verify_move_opertions(self, unit_page, source_component, operation, component_display_names_after_operation,
should_verify_publish_title=True):
"""
Verify move operations.
Arguments:
unit_page (Object) Unit container page.
source_component (Object) Source XBlock object to be moved.
operation (str), `move` or `undo move` operation.
component_display_names_after_operation (dict) Display names of components after operation in source/dest
should_verify_publish_title (Boolean) Should verify publish title ot not. Default is True.
"""
source_component.open_move_modal()
self.move_modal_view.navigate_to_category(self.source_xblock_category, self.navigation_options)
self.assertEqual(self.move_modal_view.is_move_button_enabled, True)
# Verify unit is in published state before move operation
if should_verify_publish_title:
self.container.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
self.move_modal_view.click_move_button()
self.container.verify_confirmation_message(
self.message_move.format(display_name=self.source_component_display_name)
)
self.assertEqual(len(unit_page.displayed_children), 1)
# Verify unit in draft state now
if should_verify_publish_title:
self.container.verify_publish_title(self.DRAFT_STATUS)
if operation == 'move':
self.container.click_take_me_there_link()
elif operation == 'undo_move':
self.container.click_undo_move_link()
self.container.verify_confirmation_message(
self.message_undo.format(display_name=self.source_component_display_name)
)
unit_page = ContainerPage(self.browser, None)
components = unit_page.displayed_children
self.assertEqual(
[component.name for component in components],
component_display_names_after_operation
)
def verify_state_change(self, unit_page, operation):
"""
Verify that after state change, confirmation message is hidden.
Arguments:
unit_page (Object) Unit container page.
operation (String) Publish or discard changes operation.
"""
# Verify unit in draft state now
self.container.verify_publish_title(self.DRAFT_STATUS)
# Now click publish/discard button
if operation == 'publish':
unit_page.publish_action.click()
else:
unit_page.discard_changes()
# Now verify success message is hidden
self.container.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
self.container.verify_confirmation_message(
message=self.message_move.format(display_name=self.source_component_display_name),
verify_hidden=True
)
def test_move_component_successfully(self):
"""
Test if we can move a component successfully.
Given I am a staff user
And I go to unit page in first section
And I open the move modal
And I navigate to unit in second section
And I see move button is enabled
When I click on the move button
Then I see move operation success message
And When I click on take me there link
Then I see moved component there.
"""
unit_page = self.go_to_unit_page(unit_name='Test Unit 1')
components = unit_page.displayed_children
self.assertEqual(len(components), 2)
self.verify_move_opertions(
unit_page=unit_page,
source_component=components[0],
operation='move',
component_display_names_after_operation=['HTML 21', 'HTML 22', 'HTML 11']
)
def test_undo_move_component_successfully(self):
"""
Test if we can undo move a component successfully.
Given I am a staff user
And I go to unit page in first section
And I open the move modal
When I click on the move button
Then I see move operation successful message
And When I clicked on undo move link
Then I see that undo move operation is successful
"""
unit_page = self.go_to_unit_page(unit_name='Test Unit 1')
components = unit_page.displayed_children
self.assertEqual(len(components), 2)
self.verify_move_opertions(
unit_page=unit_page,
source_component=components[0],
operation='undo_move',
component_display_names_after_operation=['HTML 11', 'HTML 12']
)
@ddt.data('publish', 'discard')
def test_publish_discard_changes_afer_move(self, operation):
"""
Test if success banner is hidden when we discard changes or publish the unit after a move operation.
Given I am a staff user
And I go to unit page in first section
And I open the move modal
And I navigate to unit in second section
And I see move button is enabled
When I click on the move button
Then I see move operation success message
And When I click on publish or discard changes button
Then I see move operation success message is hidden.
"""
unit_page = self.go_to_unit_page(unit_name='Test Unit 1')
components = unit_page.displayed_children
self.assertEqual(len(components), 2)
components[0].open_move_modal()
self.move_modal_view.navigate_to_category(self.source_xblock_category, self.navigation_options)
self.assertEqual(self.move_modal_view.is_move_button_enabled, True)
# Verify unit is in published state before move operation
self.container.verify_publish_title(self.PUBLISHED_LIVE_STATUS)
self.move_modal_view.click_move_button()
self.container.verify_confirmation_message(
self.message_move.format(display_name=self.source_component_display_name)
)
self.assertEqual(len(unit_page.displayed_children), 1)
self.verify_state_change(unit_page, operation)
def test_content_experiment(self):
"""
Test if we can move a component of content experiment successfully.
Given that I am a staff user
And I go to content experiment page
And I open the move dialogue modal
When I navigate to the unit in second section
Then I see move button is enabled
And when I click on the move button
Then I see move operation success message
And when I click on take me there link
Then I see moved component there
And when I undo move a component
Then I see that undo move operation success message
"""
# Add content experiment support to course.
self.course_fixture.add_advanced_settings({
u'advanced_modules': {'value': ['split_test']},
})
# Create group configurations
# pylint: disable=protected-access
self.course_fixture._update_xblock(self.course_fixture._course_location, {
'metadata': {
u'user_partitions': [
create_user_partition_json(
0,
'Test Group Configuration',
'Description of the group configuration.',
[Group('0', 'Group A'), Group('1', 'Group B')]
),
],
},
})
# Add split test to unit_page1 and assign newly created group configuration to it
split_test = XBlockFixtureDesc('split_test', 'Test Content Experiment', metadata={'user_partition_id': 0})
self.course_fixture.create_xblock(self.unit_page1.locator, split_test)
# Visit content experiment container page.
unit_page = ContainerPage(self.browser, split_test.locator)
unit_page.visit()
group_a_locator = unit_page.displayed_children[0].locator
# Add some components to Group A.
self.course_fixture.create_xblock(
group_a_locator, XBlockFixtureDesc('html', 'HTML 311')
)
self.course_fixture.create_xblock(
group_a_locator, XBlockFixtureDesc('html', 'HTML 312')
)
# Go to group page to move it's component.
group_container_page = ContainerPage(self.browser, group_a_locator)
group_container_page.visit()
# Verify content experiment block has correct groups and components.
components = group_container_page.displayed_children
self.assertEqual(len(components), 2)
self.source_component_display_name = 'HTML 311'
# Verify undo move operation for content experiment.
self.verify_move_opertions(
unit_page=group_container_page,
source_component=components[0],
operation='undo_move',
component_display_names_after_operation=['HTML 311', 'HTML 312'],
should_verify_publish_title=False
)
# Verify move operation for content experiment.
self.verify_move_opertions(
unit_page=group_container_page,
source_component=components[0],
operation='move',
component_display_names_after_operation=['HTML 21', 'HTML 22', 'HTML 311'],
should_verify_publish_title=False
)
def test_a11y(self):
"""
Verify move modal a11y.
"""
unit_page = self.go_to_unit_page(unit_name='Test Unit 1')
unit_page.a11y_audit.config.set_scope(
include=[".modal-window.move-modal"]
)
unit_page.a11y_audit.config.set_rules({
'ignore': [
'color-contrast', # TODO: AC-716
'link-href', # TODO: AC-716
]
})
unit_page.displayed_children[0].open_move_modal()
for category in ['section', 'subsection', 'component']:
self.move_modal_view.navigate_to_category(category, self.navigation_options)
unit_page.a11y_audit.check_for_accessibility_errors()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment