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
) )
......
...@@ -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)
self.assertEqual(first_child_response['visibility_state'], VisibilityState.unscheduled) if not is_concise:
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)
......
...@@ -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");
});
});
});
...@@ -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));
// after showing and resizing, send focus // child may want to have its own focus management
var modal = this.$el.find(this.options.modalWindowClass); if (focusModalWindow) {
modal.focus(); // after showing and resizing, send focus
this.$el.find(this.options.modalWindowClass).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
}; };
}); });
...@@ -205,7 +205,7 @@ ...@@ -205,7 +205,7 @@
text-shadow: 0 1px 0 $btn-lms-shadow; text-shadow: 0 1px 0 $btn-lms-shadow;
background-clip: padding-box; background-clip: padding-box;
font-size: 0.8125em; font-size: 0.8125em;
&:focus, &:focus,
&:hover { &:hover {
box-shadow: inset 0 1px 0 0 $btn-lms-shadow-hover; box-shadow: inset 0 1px 0 0 $btn-lms-shadow-hover;
...@@ -214,7 +214,7 @@ ...@@ -214,7 +214,7 @@
background-image: -webkit-linear-gradient($btn-lms-background-hover,$btn-lms-gradient-hover); background-image: -webkit-linear-gradient($btn-lms-background-hover,$btn-lms-gradient-hover);
background-image: linear-gradient($btn-lms-background-hover,$btn-lms-gradient-hover); background-image: linear-gradient($btn-lms-background-hover,$btn-lms-gradient-hover);
} }
&:active { &:active {
border: 1px solid $btn-lms-border; border: 1px solid $btn-lms-border;
box-shadow: inset 0 0 8px 4px $btn-lms-shadow-active,inset 0 0 8px 4px $btn-lms-shadow-active; box-shadow: inset 0 0 8px 4px $btn-lms-shadow-active,inset 0 0 8px 4px $btn-lms-shadow-active;
...@@ -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">
<ul class="editor-modes action-list action-modes"> <%- title %>
</ul> <% if (modalSRTitle) { %>
<span class="sr modal-sr-title">
<%- modalSRTitle %>
</span>
<% } %>
</h2>
<% if (showEditorModeButtons) { %>
<ul class="editor-modes action-list action-modes">
</ul>
<% } %>
</div> </div>
<div class="modal-content"> <div class="modal-content">
</div> </div>
......
<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:
...@@ -149,7 +160,7 @@ messages = xblock.validate().to_json() ...@@ -149,7 +160,7 @@ messages = xblock.validate().to_json()
${content} ${content}
</div> </div>
% endif % endif
% endif % endif
% if not is_root: % if not is_root:
<!-- footer for xblock_aside --> <!-- footer for xblock_aside -->
......
...@@ -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
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