Commit de29ef94 by Mushtaq Ali Committed by GitHub

Merge pull request #14457 from edx/mushtaq/restrict-move-action

Allow move for content experiment
parents e9b8e17f 0df36241
...@@ -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.
......
...@@ -29,7 +29,7 @@ from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW ...@@ -29,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
...@@ -675,6 +675,21 @@ def _get_source_index(source_usage_key, source_parent): ...@@ -675,6 +675,21 @@ def _get_source_index(source_usage_key, source_parent):
return None 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): 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. Move an existing xblock as a child of the supplied target_parent_usage_key.
...@@ -688,8 +703,11 @@ def _move_item(source_usage_key, target_parent_usage_key, user, target_index=Non ...@@ -688,8 +703,11 @@ def _move_item(source_usage_key, target_parent_usage_key, user, target_index=Non
JsonResponse: Information regarding move operation. It may contains error info if an invalid move operation JsonResponse: Information regarding move operation. It may contains error info if an invalid move operation
is performed. is performed.
""" """
# Get the list of all component type XBlocks # Get the list of all parentable component type XBlocks.
component_types = sorted(set(name for name, class_ in XBlock.load_classes()) - set(DIRECT_ONLY_CATEGORIES)) 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() store = modulestore()
with store.bulk_operations(source_usage_key.course_key): with store.bulk_operations(source_usage_key.course_key):
...@@ -705,18 +723,22 @@ def _move_item(source_usage_key, target_parent_usage_key, user, target_index=Non ...@@ -705,18 +723,22 @@ def _move_item(source_usage_key, target_parent_usage_key, user, target_index=Non
source_index = _get_source_index(source_usage_key, source_parent) source_index = _get_source_index(source_usage_key, source_parent)
valid_move_type = { valid_move_type = {
'vertical': source_type if source_type in component_types else 'component',
'sequential': 'vertical', 'sequential': 'vertical',
'chapter': 'sequential', 'chapter': 'sequential',
} }
if valid_move_type.get(target_parent_type, '') != source_type: 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( error = 'You can not move {source_type} into {target_parent_type}.'.format(
source_type=source_type, source_type=source_type,
target_parent_type=target_parent_type, target_parent_type=target_parent_type,
) )
elif source_parent.location == target_parent.location: elif source_parent.location == target_parent.location:
error = 'You can not move an item into the same parent.' 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 source_index is None: elif source_index is None:
error = '{source_usage_key} not found in {parent_usage_key}.'.format( error = '{source_usage_key} not found in {parent_usage_key}.'.format(
source_usage_key=unicode(source_usage_key), source_usage_key=unicode(source_usage_key),
...@@ -1093,6 +1115,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -1093,6 +1115,7 @@ 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,
...@@ -1101,6 +1124,10 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -1101,6 +1124,10 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
if is_concise: if is_concise:
if child_info and len(child_info.get('children', [])) > 0: if child_info and len(child_info.get('children', [])) > 0:
xblock_info['child_info'] = child_info 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']
xblock_info['has_children'] = xblock.has_children
else: else:
xblock_info.update({ xblock_info.update({
'edited_on': get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None, 'edited_on': get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None,
...@@ -1121,7 +1148,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -1121,7 +1148,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
'actions': xblock_actions, 'actions': xblock_actions,
'explanatory_message': explanatory_message, 'explanatory_message': explanatory_message,
'group_access': xblock.group_access, 'group_access': xblock.group_access,
'user_partitions': get_user_partition_info(xblock, course=course), 'user_partitions': user_partitions,
}) })
if xblock.category == 'sequential': if xblock.category == 'sequential':
......
...@@ -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.
......
...@@ -41,34 +41,48 @@ class StudioPageTestCase(CourseTestCase): ...@@ -41,34 +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,
# Verify drag handles always appear. '<div class="add-xblock-component new-component-item adding"></div>',
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>' can_add
self.assertIn(drag_handle_html, html) )
self.validate_html_for_action_button(
# Verify that there are no action buttons for public blocks html,
expected_button_html = [ '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>',
'<button class="btn-default edit-button action-button">', 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">', '<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">', '<button data-tooltip="Duplicate" class="btn-default duplicate-button action-button">',
'<button data-tooltip="Move" class="btn-default move-button action-button">' can_delete
] )
for button_html in expected_button_html:
self.assertIn(button_html, html)
def validate_html_for_add_buttons(self, html, can_add=True): def validate_html_for_action_button(self, html, expected_html, can_action=True):
""" """
Validate that the specified HTML has the appropriate add actions for the current publish state. Validate that the specified HTML has specific action..
""" """
# Verify that there are no add buttons for public blocks 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)
...@@ -205,13 +205,17 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe ...@@ -205,13 +205,17 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe
return category + '_display_name_' + xblockIndex; return category + '_display_name_' + xblockIndex;
}) })
); );
if (category !== 'component') { if (category === 'component') {
if (hasCurrentLocation) {
expect(displayedInfo.currentLocationText).toEqual('(Currently selected)');
}
} else {
if (hasCurrentLocation) { if (hasCurrentLocation) {
expect(displayedInfo.currentLocationText).toEqual('(Current location)'); expect(displayedInfo.currentLocationText).toEqual('(Current location)');
} }
expect(displayedInfo.forwardButtonSRTexts).toEqual( expect(displayedInfo.forwardButtonSRTexts).toEqual(
_.map(_.range(expectedXBlocksCount), function() { _.map(_.range(expectedXBlocksCount), function() {
return 'Click for children'; return 'View child items';
}) })
); );
expect(displayedInfo.forwardButtonCount).toEqual(expectedXBlocksCount); expect(displayedInfo.forwardButtonCount).toEqual(expectedXBlocksCount);
...@@ -519,15 +523,8 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe ...@@ -519,15 +523,8 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe
}); });
}); });
describe('Move an xblock', function() { describe('Move button', function() {
it('can not move in a disabled state', function() { it('is disabled when navigating to same parent', function() {
verifyMoveEnabled(false);
modal.$el.find('.modal-actions .action-move').click();
expect(modal.movedAlertView).toBeNull();
expect(getSentRequests().length).toEqual(0);
});
it('move button is disabled when navigating to same parent', function() {
// select a target parent as the same as source parent and click // select a target parent as the same as source parent and click
renderViews(courseOutline); renderViews(courseOutline);
_.each(_.range(3), function() { _.each(_.range(3), function() {
...@@ -536,7 +533,7 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe ...@@ -536,7 +533,7 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe
verifyMoveEnabled('component', true); verifyMoveEnabled('component', true);
}); });
it('move button is enabled when navigating to different parent', function() { it('is enabled when navigating to different parent', function() {
// select a target parent as the different as source parent and click // select a target parent as the different as source parent and click
renderViews(courseOutline); renderViews(courseOutline);
_.each(_.range(3), function() { _.each(_.range(3), function() {
...@@ -553,6 +550,111 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe ...@@ -553,6 +550,111 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe
verifyXBlockInfo(courseOutlineOptions, 'section', 1, 'forward', false); 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 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(modal.movedAlertView).toBeNull();
expect(getSentRequests().length).toEqual(0);
});
it('move an xblock when move button is clicked', function() { it('move an xblock when move button is clicked', function() {
var requests = AjaxHelpers.requests(this); var requests = AjaxHelpers.requests(this);
moveXBlockWithSuccess(requests); moveXBlockWithSuccess(requests);
......
...@@ -122,6 +122,7 @@ function($, Backbone, _, gettext, BaseView, XBlockViewUtils, MoveXBlockUtils, Ht ...@@ -122,6 +122,7 @@ function($, Backbone, _, gettext, BaseView, XBlockViewUtils, MoveXBlockUtils, Ht
this.moveXBlockListView = new MoveXBlockListView( this.moveXBlockListView = new MoveXBlockListView(
{ {
model: new XBlockInfoModel(courseOutlineInfo, {parse: true}), model: new XBlockInfoModel(courseOutlineInfo, {parse: true}),
sourceXBlockInfo: this.sourceXBlockInfo,
ancestorInfo: ancestorInfo ancestorInfo: ancestorInfo
} }
); );
...@@ -136,12 +137,30 @@ function($, Backbone, _, gettext, BaseView, XBlockViewUtils, MoveXBlockUtils, Ht ...@@ -136,12 +137,30 @@ function($, Backbone, _, gettext, BaseView, XBlockViewUtils, MoveXBlockUtils, Ht
} }
}, },
isValidCategory: function(sourceParentType, targetParentType, targetHasChildren) {
var basicBlockTypes = ['course', 'chapter', 'sequential', 'vertical'];
// Treat source parent component as vertical to support move child components under content experiment
// and other similar xblocks.
// eslint-disable-next-line no-param-reassign
sourceParentType = sourceParentType === 'split_test' ? 'vertical' : sourceParentType;
// 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 (targetHasChildren && !_.contains(basicBlockTypes, targetParentType) &&
targetParentType !== 'split_test') {
targetParentType = 'vertical'; // eslint-disable-line no-param-reassign
}
return targetParentType === sourceParentType;
},
enableMoveOperation: function(targetParentXBlockInfo) { enableMoveOperation: function(targetParentXBlockInfo) {
var isValidMove = false, var isValidMove = false,
sourceParentType = this.sourceParentXBlockInfo.get('category'), sourceParentType = this.sourceParentXBlockInfo.get('category'),
targetParentType = targetParentXBlockInfo.get('category'); targetParentType = targetParentXBlockInfo.get('category'),
targetHasChildren = targetParentXBlockInfo.get('has_children');
if (targetParentType === sourceParentType && this.sourceParentXBlockInfo.id !== targetParentXBlockInfo.id) { if (this.isValidCategory(sourceParentType, targetParentType, targetHasChildren) &&
this.sourceParentXBlockInfo.id !== targetParentXBlockInfo.id && // same parent case
this.sourceXBlockInfo.id !== targetParentXBlockInfo.id) { // same source item case
isValidMove = true; isValidMove = true;
this.targetParentXBlockInfo = targetParentXBlockInfo; this.targetParentXBlockInfo = targetParentXBlockInfo;
} }
......
...@@ -13,10 +13,6 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, MoveXBlockBreadcrumbVi ...@@ -13,10 +13,6 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, MoveXBlockBreadcrumbVi
var MoveXBlockBreadcrumb = Backbone.View.extend({ var MoveXBlockBreadcrumb = Backbone.View.extend({
el: '.breadcrumb-container', el: '.breadcrumb-container',
defaultRenderOptions: {
breadcrumbs: ['Course Outline']
},
events: { events: {
'click .parent-nav-button': 'handleBreadcrumbButtonPress' 'click .parent-nav-button': 'handleBreadcrumbButtonPress'
}, },
...@@ -29,7 +25,7 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, MoveXBlockBreadcrumbVi ...@@ -29,7 +25,7 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, MoveXBlockBreadcrumbVi
render: function(options) { render: function(options) {
HtmlUtils.setHtml( HtmlUtils.setHtml(
this.$el, this.$el,
this.template(_.extend({}, this.defaultRenderOptions, options)) this.template(options)
); );
Backbone.trigger('move:breadcrumbRendered'); Backbone.trigger('move:breadcrumbRendered');
return this; return this;
......
...@@ -33,7 +33,8 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBloc ...@@ -33,7 +33,8 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBloc
section: gettext('Sections'), section: gettext('Sections'),
subsection: gettext('Subsections'), subsection: gettext('Subsections'),
unit: gettext('Units'), unit: gettext('Units'),
component: gettext('Components') component: gettext('Components'),
group: gettext('Groups')
}, },
events: { events: {
...@@ -43,6 +44,7 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBloc ...@@ -43,6 +44,7 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBloc
initialize: function(options) { initialize: function(options) {
this.visitedAncestors = []; this.visitedAncestors = [];
this.template = HtmlUtils.template(MoveXBlockListViewTemplate); this.template = HtmlUtils.template(MoveXBlockListViewTemplate);
this.sourceXBlockInfo = options.sourceXBlockInfo;
this.ancestorInfo = options.ancestorInfo; this.ancestorInfo = options.ancestorInfo;
this.listenTo(Backbone, 'move:breadcrumbButtonPressed', this.handleBreadcrumbButtonPress); this.listenTo(Backbone, 'move:breadcrumbButtonPressed', this.handleBreadcrumbButtonPress);
this.renderXBlockInfo(); this.renderXBlockInfo();
...@@ -53,6 +55,7 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBloc ...@@ -53,6 +55,7 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBloc
this.$el, this.$el,
this.template( this.template(
{ {
sourceXBlockId: this.sourceXBlockInfo.id,
xblocks: this.childrenInfo.children, xblocks: this.childrenInfo.children,
noChildText: this.getNoChildText(), noChildText: this.getNoChildText(),
categoryText: this.getCategoryText(), categoryText: this.getCategoryText(),
...@@ -123,10 +126,14 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBloc ...@@ -123,10 +126,14 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBloc
* Set parent and child XBlock categories. * Set parent and child XBlock categories.
*/ */
setDisplayedXBlocksCategories: function() { setDisplayedXBlocksCategories: function() {
this.parentInfo.category = XBlockUtils.getXBlockType( var childCategory = 'component';
this.parentInfo.parent.get('category'), this.parentInfo.category = XBlockUtils.getXBlockType(this.parentInfo.parent.get('category'));
this.visitedAncestors[this.visitedAncestors.length - 2] 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]; this.childrenInfo.category = this.categoryRelationMap[this.parentInfo.category];
}, },
...@@ -136,25 +143,19 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBloc ...@@ -136,25 +143,19 @@ function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBloc
* @returns {any} Integer or undefined * @returns {any} Integer or undefined
*/ */
getCurrentLocationIndex: function() { getCurrentLocationIndex: function() {
var category, ancestorXBlock, currentLocationIndex; var self = this,
currentLocationIndex;
if (this.childrenInfo.category === 'component' || this.childrenInfo.children.length === 0) { _.each(self.childrenInfo.children, function(xblock, index) {
return currentLocationIndex; if (xblock.get('id') === self.sourceXBlockInfo.id) {
} currentLocationIndex = index;
} else {
category = this.childrenInfo.children[0].get('category'); _.each(self.ancestorInfo.ancestors, function(ancestor) {
ancestorXBlock = _.find( if (ancestor.display_name === xblock.get('display_name') && ancestor.id === xblock.get('id')) {
this.ancestorInfo.ancestors, function(ancestor) { return ancestor.category === category; } currentLocationIndex = index;
); }
});
if (ancestorXBlock) { }
_.each(this.childrenInfo.children, function(xblock, index) { });
if (ancestorXBlock.display_name === xblock.get('display_name') &&
ancestorXBlock.id === xblock.get('id')) {
currentLocationIndex = index;
}
});
}
return currentLocationIndex; return currentLocationIndex;
}, },
......
...@@ -395,20 +395,21 @@ ...@@ -395,20 +395,21 @@
} }
.component { .component {
display: block; display: inline-block;
color: $black; color: $black;
padding: ($baseline/4) ($baseline/2);
}
.xblock-displayname {
@include float(left);
} }
.button-forward, .component { .button-forward, .component {
border: none; border: none;
padding: ($baseline/2);
} }
.button-forward { .button-forward {
.xblock-displayname { padding: ($baseline/2);
@include float(left);
}
.forward-sr-icon { .forward-sr-icon {
@include float(right); @include float(right);
......
...@@ -13,16 +13,12 @@ ...@@ -13,16 +13,12 @@
<%- categoryText %>: <%- categoryText %>:
</span> </span>
</div> </div>
<ul class="xblock-items-container"> <ul class="xblock-items-container" data-items-category="<%- XBlocksCategory %>">
<% for (var i = 0; i < xblocks.length; i++) { <% for (var i = 0; i < xblocks.length; i++) {
var xblock = xblocks[i]; var xblock = xblocks[i];
%> %>
<li class="xblock-item" data-item-index="<%- i %>"> <li class="xblock-item" data-item-index="<%- i %>">
<% if (XBlocksCategory === 'component') { %> <% if (sourceXBlockId !== xblock.id && (xblock.get('child_info') || XBlocksCategory !== 'component')) { %>
<span class="xblock-displayname component truncate">
<%- xblock.get('display_name') %>
</span>
<% } else { %>
<button class="button-forward" > <button class="button-forward" >
<span class="xblock-displayname truncate"> <span class="xblock-displayname truncate">
<%- xblock.get('display_name') %> <%- xblock.get('display_name') %>
...@@ -33,8 +29,19 @@ ...@@ -33,8 +29,19 @@
</span> </span>
<% } %> <% } %>
<span class="icon fa fa-arrow-right forward-sr-icon" aria-hidden="true"></span> <span class="icon fa fa-arrow-right forward-sr-icon" aria-hidden="true"></span>
<span class="sr forward-sr-text"><%- gettext("Click for children") %></span> <span class="sr forward-sr-text"><%- gettext("View child items") %></span>
</button> </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> </li>
<% } %> <% } %>
......
...@@ -89,7 +89,8 @@ messages = xblock.validate().to_json() ...@@ -89,7 +89,8 @@ messages = xblock.validate().to_json()
<span class="sr">${_("Duplicate")}</span> <span class="sr">${_("Duplicate")}</span>
</button> </button>
</li> </li>
% endif
% if can_move:
<li class="action-item action-move"> <li class="action-item action-move">
<button data-tooltip="${_("Move")}" class="btn-default move-button action-button"> <button data-tooltip="${_("Move")}" class="btn-default move-button action-button">
<span class="stack-move-icon fa-stack fa-lg "> <span class="stack-move-icon fa-stack fa-lg ">
......
...@@ -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.
......
...@@ -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.
...@@ -262,6 +274,29 @@ class ContainerPage(PageObject, HelpMixin): ...@@ -262,6 +274,29 @@ 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 for confirmation message.
"""
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 in text[0]
self.wait_for(_verify_message, description='confirmation message 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 +417,7 @@ class XBlockWrapper(PageObject): ...@@ -382,7 +417,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.
...@@ -468,6 +503,13 @@ class XBlockWrapper(PageObject): ...@@ -468,6 +503,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 +547,15 @@ class XBlockWrapper(PageObject): ...@@ -505,6 +547,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,6 +10,7 @@ from common.test.acceptance.fixtures.course import XBlockFixtureDesc ...@@ -10,6 +10,7 @@ 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
...@@ -1136,3 +1137,231 @@ class ProblemCategoryTabsTest(ContainerBase): ...@@ -1136,3 +1137,231 @@ 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)
class MoveComponentTest(ContainerBase):
"""
Tests of moving an XBlock to another XBlock.
"""
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):
"""
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
"""
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)
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)
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 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']
)
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']
)
# 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']
)
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