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):
# Verify that the "loading" indication has been hidden.
world.wait_for_loading()
# Verify that the "edit" button is present.
world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
world.css_click('a.edit-button', index)
world.wait_for(lambda _driver: world.css_visible('.edit-button'))
world.css_click('.edit-button', index)
world.wait_for_ajax_complete()
......
......@@ -38,7 +38,7 @@ def not_see_any_static_pages(step):
@step(u'I "(edit|delete)" the static page$')
def click_edit_or_delete(step, edit_or_delete):
button_css = 'ul.component-actions a.%s-button' % edit_or_delete
button_css = 'ul.component-actions .%s-button' % edit_or_delete
world.css_click(button_css)
......
......@@ -283,6 +283,23 @@ def reverse_usage_url(handler_name, usage_key, kwargs=None):
return reverse_url(handler_name, 'usage_key_string', usage_key, kwargs)
def get_group_display_name(user_partitions, xblock_display_name):
"""
Get the group name if matching group xblock is found.
Arguments:
user_partitions (Dict): Locator of source item.
xblock_display_name (String): Display name of group xblock.
Returns:
group name (String): Group name of the matching group.
"""
for user_partition in user_partitions:
for group in user_partition['groups']:
if str(group['id']) in xblock_display_name:
return group['name']
def get_user_partition_info(xblock, schemes=None, course=None):
"""
Retrieve user partition information for an XBlock for display in editors.
......
......@@ -20,7 +20,7 @@ from xblock.exceptions import NoSuchHandlerError
from xblock.plugin import PluginMissingError
from xblock.runtime import Mixologist
from contentstore.utils import get_lms_link_for_item, get_xblock_aside_instance
from contentstore.utils import get_lms_link_for_item, reverse_course_url, get_xblock_aside_instance
from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name
from contentstore.views.item import create_xblock_info, add_container_page_publishing_info, StudioEditModuleRuntime
......@@ -165,6 +165,7 @@ def container_handler(request, usage_key_string):
'subsection': subsection,
'section': section,
'new_unit_category': 'vertical',
'outline_url': '{url}?format=concise'.format(url=reverse_course_url('course_handler', course.id)),
'ancestor_xblocks': ancestor_xblocks,
'component_templates': component_templates,
'xblock_info': xblock_info,
......
......@@ -336,11 +336,16 @@ def _course_outline_json(request, course_module):
"""
Returns a JSON representation of the course module and recursively all of its children.
"""
is_concise = request.GET.get('format') == 'concise'
include_children_predicate = lambda xblock: not xblock.category == 'vertical'
if is_concise:
include_children_predicate = lambda xblock: xblock.has_children
return create_xblock_info(
course_module,
include_child_info=True,
course_outline=True,
include_children_predicate=lambda xblock: not xblock.category == 'vertical',
course_outline=False if is_concise else True,
include_children_predicate=include_children_predicate,
is_concise=is_concise,
user=request.user
)
......
......@@ -274,6 +274,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
'can_edit': context.get('can_edit', True),
'can_edit_visibility': context.get('can_edit_visibility', True),
'can_add': context.get('can_add', True),
'can_move': context.get('can_move', True)
}
html = render_to_string('studio_xblock_wrapper.html', template_context)
frag = wrap_fragment(frag, html)
......
......@@ -12,11 +12,13 @@ from django.utils import http
import contentstore.views.component as views
from contentstore.views.tests.utils import StudioPageTestCase
from contentstore.tests.test_libraries import LibraryTestCase
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import ItemFactory
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
class ContainerPageTestCase(StudioPageTestCase):
class ContainerPageTestCase(StudioPageTestCase, LibraryTestCase):
"""
Unit tests for the container page.
"""
......@@ -128,6 +130,44 @@ class ContainerPageTestCase(StudioPageTestCase):
self.validate_preview_html(published_child_container, self.container_view)
self.validate_preview_html(published_child_vertical, self.reorderable_child_view)
def test_library_page_preview_html(self):
"""
Verify that a library xblock's container (library page) preview returns the expected HTML.
"""
# Add some content to library.
self._add_simple_content_block()
self.validate_preview_html(self.library, self.container_view, can_reorder=False, can_move=False)
def test_library_content_preview_html(self):
"""
Verify that a library content block container page preview returns the expected HTML.
"""
# Library content block is only supported in split courses.
with modulestore().default_store(ModuleStoreEnum.Type.split):
course = CourseFactory.create()
# Add some content to library
self._add_simple_content_block()
# Create a library content block
lc_block = self._add_library_content_block(course, self.lib_key)
self.assertEqual(len(lc_block.children), 0)
# Refresh children to be reflected in lc_block
lc_block = self._refresh_children(lc_block)
self.assertEqual(len(lc_block.children), 1)
self.validate_preview_html(
lc_block,
self.container_view,
can_add=False,
can_reorder=False,
can_move=False,
can_edit=True,
can_duplicate=False,
can_delete=False
)
def test_draft_container_preview_html(self):
"""
Verify that a draft xblock's container preview returns the expected HTML.
......
......@@ -352,11 +352,16 @@ class TestCourseOutline(CourseTestCase):
parent_location=self.vertical.location, category="video", display_name="My Video"
)
def test_json_responses(self):
@ddt.data(True, False)
def test_json_responses(self, is_concise):
"""
Verify the JSON responses returned for the course.
Arguments:
is_concise (Boolean) : If True, fetch concise version of course outline.
"""
outline_url = reverse_course_url('course_handler', self.course.id)
outline_url = outline_url + '?format=concise' if is_concise else outline_url
resp = self.client.get(outline_url, HTTP_ACCEPT='application/json')
json_response = json.loads(resp.content)
......@@ -364,8 +369,8 @@ class TestCourseOutline(CourseTestCase):
self.assertEqual(json_response['category'], 'course')
self.assertEqual(json_response['id'], unicode(self.course.location))
self.assertEqual(json_response['display_name'], self.course.display_name)
self.assertTrue(json_response['published'])
self.assertIsNone(json_response['visibility_state'])
self.assertNotEqual(json_response.get('published', False), is_concise)
self.assertIsNone(json_response.get('visibility_state'))
# Now verify the first child
children = json_response['child_info']['children']
......@@ -374,24 +379,25 @@ class TestCourseOutline(CourseTestCase):
self.assertEqual(first_child_response['category'], 'chapter')
self.assertEqual(first_child_response['id'], unicode(self.chapter.location))
self.assertEqual(first_child_response['display_name'], 'Week 1')
self.assertTrue(json_response['published'])
self.assertNotEqual(json_response.get('published', False), is_concise)
if not is_concise:
self.assertEqual(first_child_response['visibility_state'], VisibilityState.unscheduled)
self.assertGreater(len(first_child_response['child_info']['children']), 0)
# Finally, validate the entire response for consistency
self.assert_correct_json_response(json_response)
self.assert_correct_json_response(json_response, is_concise)
def assert_correct_json_response(self, json_response):
def assert_correct_json_response(self, json_response, is_concise=False):
"""
Asserts that the JSON response is syntactically consistent
"""
self.assertIsNotNone(json_response['display_name'])
self.assertIsNotNone(json_response['id'])
self.assertIsNotNone(json_response['category'])
self.assertTrue(json_response['published'])
self.assertNotEqual(json_response.get('published', False), is_concise)
if json_response.get('child_info', None):
for child_response in json_response['child_info']['children']:
self.assert_correct_json_response(child_response)
self.assert_correct_json_response(child_response, is_concise)
def test_course_outline_initial_state(self):
course_module = modulestore().get_item(self.course.location)
......
......@@ -41,33 +41,48 @@ class StudioPageTestCase(CourseTestCase):
resp_content = json.loads(resp.content)
return resp_content['html']
def validate_preview_html(self, xblock, view_name, can_add=True):
def validate_preview_html(self, xblock, view_name, can_add=True, can_reorder=True, can_move=True,
can_edit=True, can_duplicate=True, can_delete=True):
"""
Verify that the specified xblock's preview has the expected HTML elements.
"""
html = self.get_preview_html(xblock, view_name)
self.validate_html_for_add_buttons(html, can_add)
self.validate_html_for_action_button(
html,
'<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.
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):
def validate_html_for_action_button(self, html, expected_html, can_action=True):
"""
Validate that the specified HTML has the appropriate add actions for the current publish state.
Validate that the specified HTML has specific action..
"""
# Verify that there are no add buttons for public blocks
add_button_html = '<div class="add-xblock-component new-component-item adding"></div>'
if can_add:
self.assertIn(add_button_html, html)
if can_action:
self.assertIn(expected_html, html)
else:
self.assertNotIn(add_button_html, html)
self.assertNotIn(expected_html, html)
......@@ -6,7 +6,7 @@
'common/js/components/views/feedback_notification', 'coffee/src/ajax_prefix',
'jquery.cookie'],
function(domReady, $, str, Backbone, gettext, NotificationView) {
var main;
var main, sendJSON;
main = function() {
AjaxPrefix.addAjaxPrefix(jQuery, function() {
return $("meta[name='path_prefix']").attr('content');
......@@ -45,20 +45,26 @@
});
return msg.show();
});
$.postJSON = function(url, data, callback) {
sendJSON = function(url, data, callback, type) { // eslint-disable-line no-param-reassign
if ($.isFunction(data)) {
callback = data;
data = undefined;
}
return $.ajax({
url: url,
type: 'POST',
type: type,
contentType: 'application/json; charset=utf-8',
dataType: 'json',
data: JSON.stringify(data),
success: callback
});
};
$.postJSON = function(url, data, callback) { // eslint-disable-line no-param-reassign
return sendJSON(url, data, callback, 'POST');
};
$.patchJSON = function(url, data, callback) { // eslint-disable-line no-param-reassign
return sendJSON(url, data, callback, 'PATCH');
};
return domReady(function() {
if (window.onTouchBasedDevice()) {
return $('body').addClass('touch-based-device');
......
......@@ -282,7 +282,9 @@
'js/spec/views/pages/library_users_spec',
'js/spec/views/modals/base_modal_spec',
'js/spec/views/modals/edit_xblock_spec',
'js/spec/views/modals/move_xblock_modal_spec',
'js/spec/views/modals/validation_error_modal_spec',
'js/spec/views/move_xblock_spec',
'js/spec/views/settings/main_spec',
'js/spec/factories/xblock_validation_spec',
'js/certificates/spec/models/certificate_spec',
......
......@@ -50,6 +50,10 @@ function(Backbone, _, str, ModuleUtils) {
*/
'published_by': null,
/**
* True if the xblock is a parentable xblock.
*/
has_children: null,
/**
* True if the xblock has changes.
* Note: this is not always provided as a performance optimization. It is only provided for
* verticals functioning as units.
......
......@@ -69,7 +69,7 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'js/spec_
// Give the leaf elements some height to mimic actual components. Otherwise
// drag and drop fails as the elements on bunched on top of each other.
$('.level-element').css('height', 200);
$('.level-element').css('height', 230);
return requests;
};
......@@ -92,7 +92,7 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'js/spec_
var targetElement = getComponent(targetLocator),
targetTop = targetElement.offset().top + 1,
handle = getDragHandle(sourceLocator),
handleY = handle.offset().top + (handle.height() / 2),
handleY = handle.offset().top,
dy = targetTop - handleY;
handle.simulate('drag', {dy: dy});
};
......
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
mockXBlockVisibilityEditorHtml = readFixtures('mock/mock-xblock-visibility-editor.underscore'),
PageClass = globalPageOptions.page,
pagedSpecificTests = globalPageOptions.pagedSpecificTests,
hasVisibilityEditor = globalPageOptions.hasVisibilityEditor;
hasVisibilityEditor = globalPageOptions.hasVisibilityEditor,
hasMoveModal = globalPageOptions.hasMoveModal;
beforeEach(function() {
var newDisplayName = 'New Display Name';
......@@ -48,6 +49,9 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
afterEach(function() {
EditHelpers.uninstallMockXBlock();
if (containerPage !== undefined) {
containerPage.remove();
}
});
respondWithHtml = function(html) {
......@@ -250,6 +254,19 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
expect(visibilityButtons.length).toBe(0);
}
});
it('can show a move modal for a child xblock', function() {
var moveButtons;
renderContainerPage(this, mockContainerXBlockHtml);
moveButtons = containerPage.$('.wrapper-xblock .move-button');
if (hasMoveModal) {
expect(moveButtons.length).toBe(6);
moveButtons[0].click();
expect(EditHelpers.isShowingModal()).toBeTruthy();
} else {
expect(moveButtons.length).toBe(0);
}
});
});
describe('Editing an xmodule', function() {
......@@ -798,7 +815,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
initial: 'mock/mock-container-xblock.underscore',
addResponse: 'mock/mock-xblock.underscore',
hasVisibilityEditor: true,
pagedSpecificTests: false
pagedSpecificTests: false,
hasMoveModal: true
}
);
......@@ -811,7 +829,8 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
initial: 'mock/mock-container-paged-xblock.underscore',
addResponse: 'mock/mock-xblock-paged.underscore',
hasVisibilityEditor: false,
pagedSpecificTests: true
pagedSpecificTests: true,
hasMoveModal: false
}
);
});
......@@ -35,6 +35,9 @@ define(['jquery', 'underscore', 'underscore.string', 'edx-ui-toolkit/js/utils/sp
afterEach(function() {
delete window.course;
if (containerPage !== undefined) {
containerPage.remove();
}
});
defaultXBlockInfo = {
......
......@@ -16,8 +16,11 @@
* size of the modal.
* viewSpecificClasses: A string of CSS classes to be attached to
* the modal window.
* addSaveButton: A boolean indicating whether to include a save
* addPrimaryActionButton: A boolean indicating whether to include a primary action
* button on the modal.
* primaryActionButtonType: A string to be used as type for primary action button.
* primaryActionButtonTitle: A string to be used as title for primary action button.
* showEditorModeButtons: Whether to show editor mode button in the modal header.
*/
define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
function($, _, gettext, BaseView) {
......@@ -36,7 +39,11 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
title: '',
modalWindowClass: '.modal-window',
// A list of class names, separated by space.
viewSpecificClasses: ''
viewSpecificClasses: '',
addPrimaryActionButton: false,
primaryActionButtonType: 'save',
primaryActionButtonTitle: gettext('Save'),
showEditorModeButtons: true
}),
initialize: function() {
......@@ -61,6 +68,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
type: this.options.modalType,
size: this.options.modalSize,
title: this.getTitle(),
modalSRTitle: this.options.modalSRTitle,
showEditorModeButtons: this.options.showEditorModeButtons,
viewSpecificClasses: this.options.viewSpecificClasses
}));
this.addActionButtons();
......@@ -84,14 +93,17 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
return '';
},
show: function() {
show: function(focusModal) {
var focusModalWindow = focusModal === undefined;
this.render();
this.resize();
$(window).resize(_.bind(this.resize, this));
// child may want to have its own focus management
if (focusModalWindow) {
// after showing and resizing, send focus
var modal = this.$el.find(this.options.modalWindowClass);
modal.focus();
this.$el.find(this.options.modalWindowClass).focus();
}
},
hide: function() {
......@@ -112,8 +124,12 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'],
* Adds the action buttons to the modal.
*/
addActionButtons: function() {
if (this.options.addSaveButton) {
this.addActionButton('save', gettext('Save'), true);
if (this.options.addPrimaryActionButton) {
this.addActionButton(
this.options.primaryActionButtonType,
this.options.primaryActionButtonTitle,
true
);
}
this.addActionButton('cancel', gettext('Cancel'));
},
......
......@@ -25,7 +25,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
options: $.extend({}, BaseModal.prototype.options, {
modalName: 'course-outline',
modalType: 'edit-settings',
addSaveButton: true,
addPrimaryActionButton: true,
modalSize: 'med',
viewSpecificClasses: 'confirm',
editors: []
......
......@@ -4,9 +4,9 @@
* and upon save an optional refresh function can be invoked to update the display.
*/
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common/js/components/utils/view_utils',
'js/models/xblock_info', 'js/views/xblock_editor'],
function($, _, gettext, BaseModal, ViewUtils, XBlockInfo, XBlockEditorView) {
'strict mode';
'js/views/utils/xblock_utils', 'js/views/xblock_editor'],
function($, _, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockEditorView) {
'use strict';
var EditXBlockModal = BaseModal.extend({
events: _.extend({}, BaseModal.prototype.events, {
......@@ -16,11 +16,11 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
options: $.extend({}, BaseModal.prototype.options, {
modalName: 'edit-xblock',
addSaveButton: true,
view: 'studio_view',
viewSpecificClasses: 'modal-editor confirm',
// Translators: "title" is the name of the current component being edited.
titleFormat: gettext('Editing: %(title)s')
titleFormat: gettext('Editing: %(title)s'),
addPrimaryActionButton: true
}),
initialize: function() {
......@@ -37,7 +37,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
*/
edit: function(xblockElement, rootXBlockInfo, options) {
this.xblockElement = xblockElement;
this.xblockInfo = this.findXBlockInfo(xblockElement, rootXBlockInfo);
this.xblockInfo = XBlockViewUtils.findXBlockInfo(xblockElement, rootXBlockInfo);
this.options.modalType = this.xblockInfo.get('category');
this.editOptions = options;
this.render();
......@@ -183,28 +183,6 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
this.editorView.notifyRuntime('modal-hidden');
},
findXBlockInfo: function(xblockWrapperElement, defaultXBlockInfo) {
var xblockInfo = defaultXBlockInfo,
xblockElement,
displayName;
if (xblockWrapperElement.length > 0) {
xblockElement = xblockWrapperElement.find('.xblock');
displayName = xblockWrapperElement.find('.xblock-header .header-details .xblock-display-name').text().trim();
// If not found, try looking for the old unit page style rendering.
// Only used now by static pages.
if (!displayName) {
displayName = this.xblockElement.find('.component-header').text().trim();
}
xblockInfo = new XBlockInfo({
id: xblockWrapperElement.data('locator'),
courseKey: xblockWrapperElement.data('course-key'),
category: xblockElement.data('block-type'),
display_name: displayName
});
}
return xblockInfo;
},
addModeButton: function(mode, displayName) {
var buttonPanel = this.$('.editor-modes');
buttonPanel.append(this.editorModeButtonTemplate({
......
/**
* 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 @@
* XBlockContainerPage is used to display Studio's container page for an xblock which has children.
* This page allows the user to understand and manipulate the xblock and its children.
*/
define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/js/components/utils/view_utils',
'js/views/container', 'js/views/xblock', 'js/views/components/add_xblock', 'js/views/modals/edit_xblock',
define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page',
'common/js/components/utils/view_utils', 'js/views/container', 'js/views/xblock',
'js/views/components/add_xblock', 'js/views/modals/edit_xblock', 'js/views/modals/move_xblock_modal',
'js/models/xblock_info', 'js/views/xblock_string_field_editor', 'js/views/pages/container_subviews',
'js/views/unit_outline', 'js/views/utils/xblock_utils'],
function($, _, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent,
EditXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews, UnitOutlineView,
XBlockUtils) {
function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent,
EditXBlockModal, MoveXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews,
UnitOutlineView, XBlockUtils) {
'use strict';
var XBlockContainerPage = BasePage.extend({
// takes XBlockInfo as a model
......@@ -17,6 +18,7 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
'click .edit-button': 'editXBlock',
'click .visibility-button': 'editVisibilitySettings',
'click .duplicate-button': 'duplicateXBlock',
'click .move-button': 'showMoveXBlockModal',
'click .delete-button': 'deleteXBlock',
'click .new-component-button': 'scrollToNewComponentButtons'
},
......@@ -80,6 +82,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
});
this.unitOutlineView.render();
}
this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved);
},
getViewParameters: function() {
......@@ -191,6 +195,20 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
this.duplicateComponent(this.findXBlockElement(event.target));
},
showMoveXBlockModal: function(event) {
var xblockElement = this.findXBlockElement(event.target),
parentXBlockElement = xblockElement.parents('.studio-xblock-wrapper'),
modal = new MoveXBlockModal({
sourceXBlockInfo: XBlockUtils.findXBlockInfo(xblockElement, this.model),
sourceParentXBlockInfo: XBlockUtils.findXBlockInfo(parentXBlockElement, this.model),
XBlockURLRoot: this.getURLRoot(),
outlineURL: this.options.outlineURL
});
event.preventDefault();
modal.show();
},
deleteXBlock: function(event) {
event.preventDefault();
this.deleteComponent(this.findXBlockElement(event.target));
......@@ -268,6 +286,13 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j
this.model.fetch();
},
/*
After move operation is complete, updates the xblock information from server .
*/
onXBlockMoved: function() {
this.model.fetch();
},
onNewXBlock: function(xblockElement, scrollOffset, is_duplicate, data) {
ViewUtils.setScrollOffset(xblockElement, scrollOffset);
xblockElement.data('locator', data.locator);
......
......@@ -2,10 +2,11 @@
* Subviews (usually small side panels) for XBlockContainerPage.
*/
define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/components/utils/view_utils',
'js/views/utils/xblock_utils'],
function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils) {
var VisibilityState = XBlockViewUtils.VisibilityState,
disabledCss = 'is-disabled';
'js/views/utils/xblock_utils', 'js/views/utils/move_xblock_utils'],
function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils) {
'use strict';
var disabledCss = 'is-disabled';
/**
* A view that refreshes the view when certain values in the XBlockInfo have changed
......@@ -132,6 +133,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
return xblockInfo.save({publish: 'make_public'}, {patch: true});
}).always(function() {
xblockInfo.set('publish', null);
// Hide any move notification if present.
MoveXBlockUtils.hideMovedNotification();
}).done(function() {
xblockInfo.fetch();
});
......@@ -151,6 +154,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/compo
return xblockInfo.save({publish: 'discard_changes'}, {patch: true});
}).always(function() {
xblockInfo.set('publish', null);
// Hide any move notification if present.
MoveXBlockUtils.hideMovedNotification();
}).done(function() {
renderPage();
});
......
/**
* 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 @@
* Provides utilities for views to work with xblocks.
*/
define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_utils', 'js/utils/module',
'edx-ui-toolkit/js/utils/string-utils'],
function($, _, gettext, ViewUtils, ModuleUtils, StringUtils) {
'js/models/xblock_info', 'edx-ui-toolkit/js/utils/string-utils'],
function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) {
'use strict';
var addXBlock, duplicateXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField, VisibilityState,
getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields, getXBlockType;
getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields, getXBlockType, findXBlockInfo,
moveXBlock;
/**
* Represents the possible visibility states for an xblock:
......@@ -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.
* @param xblockInfo The model for 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
return xblockType;
};
findXBlockInfo = function(xblockWrapperElement, defaultXBlockInfo) {
var xblockInfo = defaultXBlockInfo,
xblockElement,
displayName,
hasChildren;
if (xblockWrapperElement.length > 0) {
xblockElement = xblockWrapperElement.find('.xblock');
displayName = xblockWrapperElement.find(
'.xblock-header .header-details .xblock-display-name'
).text().trim();
// If not found, try looking for the old unit page style rendering.
// Only used now by static pages.
if (!displayName) {
displayName = xblockElement.find('.component-header').text().trim();
}
hasChildren = defaultXBlockInfo ? defaultXBlockInfo.get('has_children') : false;
xblockInfo = new XBlockInfo({
id: xblockWrapperElement.data('locator'),
courseKey: xblockWrapperElement.data('course-key'),
category: xblockElement.data('block-type'),
display_name: displayName,
has_children: hasChildren
});
}
return xblockInfo;
};
return {
'VisibilityState': VisibilityState,
'addXBlock': addXBlock,
VisibilityState: VisibilityState,
addXBlock: addXBlock,
moveXBlock: moveXBlock,
duplicateXBlock: duplicateXBlock,
'deleteXBlock': deleteXBlock,
'updateXBlockField': updateXBlockField,
'getXBlockVisibilityClass': getXBlockVisibilityClass,
'getXBlockListTypeClass': getXBlockListTypeClass,
'updateXBlockFields': updateXBlockFields,
'getXBlockType': getXBlockType
deleteXBlock: deleteXBlock,
updateXBlockField: updateXBlockField,
getXBlockVisibilityClass: getXBlockVisibilityClass,
getXBlockListTypeClass: getXBlockListTypeClass,
updateXBlockFields: updateXBlockFields,
getXBlockType: getXBlockType,
findXBlockInfo: findXBlockInfo
};
});
......@@ -336,6 +336,28 @@
&.toggle-action {
// TODO: generalize and move checkbox styling in from static-pages and assets sass
}
.btn-default.delete-button {
border: none;
}
.btn-default.edit-button {
font-weight: 300;
}
.stack-move-icon {
font-size: 0.52em;
@include rtl {
.fa-file-o {
@include transform(rotateY(180deg));
}
.fa-arrow-right {
@include transform(rotate(180deg));
}
}
}
}
}
......
......@@ -285,6 +285,25 @@
// specific modal overrides
// ------------------------
// Move XBlock Modal
.modal-window.move-modal {
top: 10% !important;
}
.move-xblock-modal {
.modal-content {
padding: ($baseline/2) ($baseline/2) ($baseline*1.25) ($baseline/2);
}
.ui-loading {
box-shadow: none;
}
.modal-actions .action-move.is-disabled {
border: 1px solid $gray-l1 !important;
background: $gray-l1 !important;
}
}
// upload modal
.assetupload-modal {
......
......@@ -278,3 +278,5 @@ $body-line-height: golden-ratio(.875em, 1);
// carried over from LMS for xmodules
$action-primary-active-bg: #1AA1DE !default; // $m-blue
$very-light-text: $white !default;
$color-background-alternate: rgb(242, 248, 251) !default;
......@@ -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 @@
</div>
<ul class="component-actions">
<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="action-button-text">${_("Edit")}</span>
</a>
</button>
</li>
<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="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 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="sr">${_("Delete this component")}</span>
</a>
</button>
</li>
</ul>
</div>
......
......@@ -43,7 +43,8 @@ from openedx.core.djangolib.markup import HTML, Text
"${action | n, js_escaped_string}",
{
isUnitPage: ${is_unit_page | n, dump_js_escaped_json},
canEdit: true
canEdit: true,
outlineURL: "${outline_url | n, js_escaped_string}"
}
);
});
......
......@@ -5,9 +5,18 @@
<div class="modal-window <%- viewSpecificClasses %> modal-<%- size %> modal-type-<%- type %>" tabindex="-1" aria-labelledby="modal-window-title">
<div class="<%- name %>-modal">
<div class="modal-header">
<h2 id="modal-window-title" class="title modal-window-title"><%- title %></h2>
<h2 id="modal-window-title" class="title modal-window-title">
<%- title %>
<% if (modalSRTitle) { %>
<span class="sr modal-sr-title">
<%- modalSRTitle %>
</span>
<% } %>
</h2>
<% if (showEditorModeButtons) { %>
<ul class="editor-modes action-list action-modes">
</ul>
<% } %>
</div>
<div class="modal-content">
</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()
% if can_edit:
% if not show_inline:
<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="action-button-text">${_("Edit")}</span>
</a>
</button>
</li>
% if can_edit_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="sr">${_("Visibility")}</span>
</a>
</button>
</li>
% endif
% if can_add:
<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="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>
% endif
% endif
% if can_add:
<!-- If we can add, we can 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="sr">${_("Delete")}</span>
</a>
</button>
</li>
% endif
% if is_reorderable:
......
......@@ -338,6 +338,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
'display_name': self.display_name or self.url_name,
}))
context['can_edit_visibility'] = False
context['can_move'] = 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 -
# just the status of this block in the validation area.
......
......@@ -80,6 +80,7 @@ class LibraryRoot(XBlock):
children_to_show = self.children[item_start:item_end] # pylint: disable=no-member
force_render = context.get('force_render', None)
context['can_move'] = False
for child_key in children_to_show:
# Children must have a separate context from the library itself. Make a copy.
......
......@@ -78,16 +78,17 @@
return this;
},
inFocus: function() {
inFocus: function(wrapperElementSelector) {
var wrapper = wrapperElementSelector || '.wrapper',
tabbables;
this.options.outFocusElement = this.options.outFocusElement || document.activeElement;
// Set focus to the container.
this.$('.wrapper').first().focus();
this.$(wrapper).first().focus();
// Make tabs within the prompt loop rather than setting focus
// back to the main content of the page.
var tabbables = this.$(tabbable_elements.join());
tabbables = this.$(tabbable_elements.join());
tabbables.on('keydown', function(event) {
// On tab backward from the first tabbable item in the prompt
if (event.which === 9 && event.shiftKey && event.target === tabbables.first()[0]) {
......
......@@ -63,11 +63,16 @@ class ContainerPage(PageObject, HelpMixin):
is_done = num_wrappers == (num_initialized_xblocks + num_failed_xblocks)
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,
# and then wait for the loading spinner to go away and all the xblocks to be initialized.
return (
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()
)
......@@ -102,6 +107,13 @@ class ContainerPage(PageObject, HelpMixin):
return self._get_xblocks(".is-active ")
@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):
"""
Returns the title as displayed on the publishing sidebar component.
......@@ -217,6 +229,18 @@ class ContainerPage(PageObject, HelpMixin):
self.q(css='.button-view').first.click()
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):
"""
Clicks "Preview", which will open the draft version of the unit page in the LMS.
......@@ -243,7 +267,7 @@ class ContainerPage(PageObject, HelpMixin):
"""
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):
"""
......@@ -252,7 +276,7 @@ class ContainerPage(PageObject, HelpMixin):
The index of the first item is 0.
"""
# 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
confirm_prompt(self)
......@@ -262,6 +286,31 @@ class ContainerPage(PageObject, HelpMixin):
"""
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):
"""
Click the "add missing groups" link.
......@@ -382,7 +431,7 @@ class XBlockWrapper(PageObject):
"""
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
# Now remove any non-direct descendants.
......@@ -451,14 +500,14 @@ class XBlockWrapper(PageObject):
"""
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
def has_delete_button(self):
"""
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
def has_edit_visibility_button(self):
......@@ -468,6 +517,13 @@ class XBlockWrapper(PageObject):
"""
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):
"""
Open the container page linked to by this xblock, and return
......@@ -505,6 +561,15 @@ class XBlockWrapper(PageObject):
"""
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):
"""
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