Commit 11c25e8c by Andy Armstrong

Add Studio handling for changes to group configurations

STUD-1658
parent 8239f0e7
......@@ -7,6 +7,9 @@ the top. Include a label indicating the component affected.
Studio: Move Peer Assessment into advanced problems menu.
Studio: Support creation and editing of split_test instances (Content Experiments)
entirely in Studio. STUD-1658.
Blades: Add context-aware video index. BLD-933
Blades: Fix bug with incorrect link format and redirection. BLD-1049
......
......@@ -180,7 +180,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
xblock = store.get_item(usage_key)
is_read_only = _is_xblock_read_only(xblock)
container_views = ['container_preview', 'reorderable_container_child_preview']
unit_views = ['student_view']
unit_views = ['student_view', 'author_view']
# wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly
......@@ -213,7 +213,6 @@ def xblock_view_handler(request, usage_key_string, view_name):
# Note: this special case logic can be removed once the unit page is replaced
# with the new container view.
context = {
'runtime_type': 'studio',
'container_view': is_container_view,
'read_only': is_read_only,
'root_xblock': xblock if (view_name == 'container_preview') else None,
......
......@@ -21,13 +21,14 @@ from xblock.exceptions import NoSuchHandlerError
from xblock.fragment import Fragment
from lms.lib.xblock.field_data import LmsFieldData
from cms.lib.xblock.field_data import CmsFieldData
from cms.lib.xblock.runtime import local_resource_url
from util.sandboxing import can_execute_unsafe_code
import static_replace
from .session_kv_store import SessionKeyValueStore
from .helpers import render_from_lms, xblock_has_own_studio_page
from .helpers import render_from_lms
from contentstore.views.access import get_user_role
......@@ -143,15 +144,20 @@ def _preview_module_system(request, descriptor):
def _load_preview_module(request, descriptor):
"""
Return a preview XModule instantiated from the supplied descriptor.
Return a preview XModule instantiated from the supplied descriptor. Will use mutable fields
if XModule supports an author_view. Otherwise, will use immutable fields and student_view.
request: The active django request
descriptor: An XModuleDescriptor
"""
student_data = KvsFieldData(SessionKeyValueStore(request))
if _has_author_view(descriptor):
field_data = CmsFieldData(descriptor._field_data, student_data) # pylint: disable=protected-access
else:
field_data = LmsFieldData(descriptor._field_data, student_data) # pylint: disable=protected-access
descriptor.bind_for_student(
_preview_module_system(request, descriptor),
LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access
field_data
)
return descriptor
......@@ -169,7 +175,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
Wraps the results of rendering an XBlock view in a div which adds a header and Studio action buttons.
"""
# Only add the Studio wrapper when on the container page. The unit page will remain as is for now.
if context.get('container_view', None) and view == 'student_view':
if context.get('container_view', None) and view in ['student_view', 'author_view']:
root_xblock = context.get('root_xblock')
is_root = root_xblock and xblock.location == root_xblock.location
is_reorderable = _is_xblock_reorderable(xblock, context)
......@@ -187,14 +193,25 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
def get_preview_fragment(request, descriptor, context):
"""
Returns the HTML returned by the XModule's student_view,
Returns the HTML returned by the XModule's student_view or author_view (if available),
specified by the descriptor and idx.
"""
module = _load_preview_module(request, descriptor)
preview_view = 'author_view' if _has_author_view(module) else 'student_view'
try:
fragment = module.render("student_view", context)
fragment = module.render(preview_view, context)
except Exception as exc: # pylint: disable=W0703
log.warning("Unable to render student_view for %r", module, exc_info=True)
log.warning("Unable to render %s for %r", preview_view, module, exc_info=True)
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
return fragment
def _has_author_view(descriptor):
"""
Returns True if the xmodule linked to the descriptor supports "author_view".
If False, "student_view" and LmsFieldData should be used.
"""
return getattr(descriptor, 'has_author_view', False)
......@@ -106,7 +106,7 @@ class GetItem(ItemTest):
self.assertNotIn('wrapper-xblock', html)
# Verify that the header and article tags are still added
self.assertIn('<header class="xblock-header">', html)
self.assertIn('<header class="xblock-header xblock-header-vertical">', html)
self.assertIn('<article class="xblock-render">', html)
def test_get_container_fragment(self):
......@@ -122,7 +122,7 @@ class GetItem(ItemTest):
# Verify that the Studio nesting wrapper has been added
self.assertIn('level-nesting', html)
self.assertIn('<header class="xblock-header">', html)
self.assertIn('<header class="xblock-header xblock-header-vertical">', html)
self.assertIn('<article class="xblock-render">', html)
# Verify that the Studio element wrapper has been added
......@@ -811,7 +811,15 @@ class TestEditSplitModule(ItemTest):
self.assertEqual(partition_id, split_test.user_partition_id)
return split_test
def test_split_create_groups(self):
def _assert_children(self, expected_number):
"""
Verifies the number of children of the split_test instance.
"""
split_test = self.get_item_from_modulestore(self.split_test_usage_key, True)
self.assertEqual(expected_number, len(split_test.children))
return split_test
def test_create_groups(self):
"""
Test that verticals are created for the experiment groups when
a spit test module is edited.
......@@ -833,16 +841,14 @@ class TestEditSplitModule(ItemTest):
self.assertEqual("alpha", vertical_0.display_name)
self.assertEqual("beta", vertical_1.display_name)
# Verify that the group_id_to child mapping is correct.
# Verify that the group_id_to_child mapping is correct.
self.assertEqual(2, len(split_test.group_id_to_child))
split_test.group_id_to_child['0'] = vertical_0.location
split_test.group_id_to_child['1'] = vertical_1.location
self.assertEqual(vertical_0.location, split_test.group_id_to_child['0'])
self.assertEqual(vertical_1.location, split_test.group_id_to_child['1'])
def test_split_change_user_partition_id(self):
def test_change_user_partition_id(self):
"""
Test what happens when the user_partition_id is changed to a different experiment.
This is not currently supported by the Studio UI.
"""
# Set to first experiment.
split_test = self._update_partition_id(0)
......@@ -852,21 +858,23 @@ class TestEditSplitModule(ItemTest):
# Set to second experiment
split_test = self._update_partition_id(1)
# We don't currently remove existing children.
# We don't remove existing children.
self.assertEqual(5, len(split_test.children))
self.assertEqual(initial_vertical_0_location, split_test.children[0])
self.assertEqual(initial_vertical_1_location, split_test.children[1])
vertical_0 = self.get_item_from_modulestore(split_test.children[2], True)
vertical_1 = self.get_item_from_modulestore(split_test.children[3], True)
vertical_2 = self.get_item_from_modulestore(split_test.children[4], True)
# Verify that the group_id_to child mapping is correct.
self.assertEqual(3, len(split_test.group_id_to_child))
split_test.group_id_to_child['0'] = vertical_0.location
split_test.group_id_to_child['1'] = vertical_1.location
split_test.group_id_to_child['2'] = vertical_2.location
self.assertEqual(vertical_0.location, split_test.group_id_to_child['0'])
self.assertEqual(vertical_1.location, split_test.group_id_to_child['1'])
self.assertEqual(vertical_2.location, split_test.group_id_to_child['2'])
self.assertNotEqual(initial_vertical_0_location, vertical_0.location)
self.assertNotEqual(initial_vertical_1_location, vertical_1.location)
def test_split_same_user_partition_id(self):
def test_change_same_user_partition_id(self):
"""
Test that nothing happens when the user_partition_id is set to the same value twice.
"""
......@@ -880,7 +888,7 @@ class TestEditSplitModule(ItemTest):
self.assertEqual(2, len(split_test.children))
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child)
def test_split_non_existent_user_partition_id(self):
def test_change_non_existent_user_partition_id(self):
"""
Test that nothing happens when the user_partition_id is set to a value that doesn't exist.
......@@ -896,6 +904,80 @@ class TestEditSplitModule(ItemTest):
self.assertEqual(2, len(split_test.children))
self.assertEqual(initial_group_id_to_child, split_test.group_id_to_child)
def test_delete_children(self):
"""
Test that deleting a child in the group_id_to_child map updates the map.
Also test that deleting a child not in the group_id_to_child_map behaves properly.
"""
# Set to first experiment.
self._update_partition_id(0)
split_test = self._assert_children(2)
vertical_1_usage_key = split_test.children[1]
# Add an extra child to the split_test
resp = self.create_xblock(category='html', parent_usage_key=self.split_test_usage_key)
extra_child_usage_key = self.response_usage_key(resp)
self._assert_children(3)
# Remove the first child (which is part of the group configuration).
resp = self.client.ajax_post(
self.split_test_update_url,
data={'children': [unicode(vertical_1_usage_key), unicode(extra_child_usage_key)]}
)
self.assertEqual(resp.status_code, 200)
split_test = self._assert_children(2)
# Check that group_id_to_child was updated appropriately
group_id_to_child = split_test.group_id_to_child
self.assertEqual(1, len(group_id_to_child))
self.assertEqual(vertical_1_usage_key, group_id_to_child['1'])
# Remove the "extra" child and make sure that group_id_to_child did not change.
resp = self.client.ajax_post(
self.split_test_update_url,
data={'children': [unicode(vertical_1_usage_key)]}
)
self.assertEqual(resp.status_code, 200)
split_test = self._assert_children(1)
self.assertEqual(group_id_to_child, split_test.group_id_to_child)
def test_add_groups(self):
"""
Test the "fix up behavior" when groups are missing (after a group is added to a group configuration).
This test actually belongs over in common, but it relies on a mutable modulestore.
TODO: move tests that can go over to common after the mixed modulestore work is done. # pylint: disable=fixme
"""
# Set to first group configuration.
split_test = self._update_partition_id(0)
# Add a group to the first group configuration.
split_test.user_partitions = [
UserPartition(
0, 'first_partition', 'First Partition',
[Group("0", 'alpha'), Group("1", 'beta'), Group("2", 'pie')]
)
]
self.store.update_item(split_test, self.user.id)
# group_id_to_child and children have not changed yet.
split_test = self._assert_children(2)
group_id_to_child = split_test.group_id_to_child
self.assertEqual(2, len(group_id_to_child))
# Call add_missing_groups method to add the missing group.
split_test.add_missing_groups(None)
split_test = self._assert_children(3)
self.assertNotEqual(group_id_to_child, split_test.group_id_to_child)
group_id_to_child = split_test.group_id_to_child
self.assertEqual(split_test.children[2], group_id_to_child["2"])
# Call add_missing_groups again -- it should be a no-op.
split_test.add_missing_groups(None)
split_test = self._assert_children(3)
self.assertEqual(group_id_to_child, split_test.group_id_to_child)
@ddt.ddt
class TestComponentHandler(TestCase):
......
"""
:class:`~xblock.field_data.FieldData` subclasses used by the CMS
"""
from xblock.field_data import SplitFieldData
from xblock.fields import Scope
class CmsFieldData(SplitFieldData):
"""
A :class:`~xblock.field_data.FieldData` that
reads all UserScope.ONE and UserScope.ALL fields from `student_data`
and all UserScope.NONE fields from `authored_data`. It allows writing to`authored_data`.
"""
def __init__(self, authored_data, student_data):
# Make sure that we don't repeatedly nest CmsFieldData instances
if isinstance(authored_data, CmsFieldData):
authored_data = authored_data._authored_data # pylint: disable=protected-access
self._authored_data = authored_data
self._student_data = student_data
super(CmsFieldData, self).__init__({
Scope.content: authored_data,
Scope.settings: authored_data,
Scope.parent: authored_data,
Scope.children: authored_data,
Scope.user_state_summary: student_data,
Scope.user_state: student_data,
Scope.user_info: student_data,
Scope.preferences: student_data,
})
......@@ -233,6 +233,8 @@ define([
"js/spec/views/modals/base_modal_spec",
"js/spec/views/modals/edit_xblock_spec",
"js/spec/xblock/cms.runtime.v1_spec",
# these tests are run separately in the cms-squire suite, due to process
# isolation issues with Squire.js
# "coffee/spec/views/assets_spec"
......
define [
"jquery", "xblock/runtime.v1", "URI", "gettext",
"jquery", "backbone", "xblock/runtime.v1", "URI", "gettext",
"js/utils/modal", "js/views/feedback_notification"
], ($, XBlock, URI, gettext, ModalUtils, NotificationView) ->
@PreviewRuntime = {}
], ($, Backbone, XBlock, URI, gettext, ModalUtils, NotificationView) ->
@BaseRuntime = {}
class PreviewRuntime.v1 extends XBlock.Runtime.v1
class BaseRuntime.v1 extends XBlock.Runtime.v1
handlerUrl: (element, handlerName, suffix, query, thirdparty) ->
uri = URI("/preview/xblock").segment($(element).data('usage-id'))
uri = URI(@handlerPrefix).segment($(element).data('usage-id'))
.segment('handler')
.segment(handlerName)
if suffix? then uri.segment(suffix)
if query? then uri.search(query)
uri.toString()
@StudioRuntime = {}
class StudioRuntime.v1 extends XBlock.Runtime.v1
constructor: () ->
super()
@savingNotification = new NotificationView.Mini
title: gettext('Saving&hellip;')
@alert = new NotificationView.Error
title: "OpenAssessment Save Error",
closeIcon: false,
shown: false
@dispatcher = _.clone(Backbone.Events)
@listenTo('save', @_handleSave)
@listenTo('cancel', @_handleCancel)
@listenTo('error', @_handleError)
@listenTo('modal-shown', (data) ->
@modal = data)
@listenTo('modal-hidden', () ->
@modal = null)
@listenTo('page-shown', (data) ->
@page = data)
handlerUrl: (element, handlerName, suffix, query, thirdparty) ->
uri = URI("/xblock").segment($(element).data('usage-id'))
.segment('handler')
.segment(handlerName)
if suffix? then uri.segment(suffix)
if query? then uri.search(query)
uri.toString()
# Notify the Studio client-side runtime so it can update
# the UI in a consistent way. Currently, this is used
# for save / cancel when editing an XBlock.
# Although native XBlocks should handle their own persistence,
# Studio still needs to update the UI in a consistent way
# (showing the "Saving..." notification, closing the modal editing dialog, etc.)
# Notify the Studio client-side runtime of an event so that it can update the UI in a consistent way.
notify: (name, data) ->
if name == 'save'
if 'state' of data
@dispatcher.trigger(name, data)
# Starting to save, so show the "Saving..." notification
if data.state == 'start'
@savingNotification.show()
# Listen to a Studio event and invoke the specified callback when it is triggered.
listenTo: (name, callback) ->
@dispatcher.bind(name, callback, this)
# Finished saving, so hide the "Saving..." notification
else if data.state == 'end'
@_hideAlerts()
# Refresh the view for the xblock represented by the specified element.
refreshXBlock: (element) ->
if @page
@page.refreshXBlock(element)
# Notify the modal that the save has completed so that it can hide itself
# and then refresh the xblock.
if @modal
@modal.onSave()
_handleError: (data) ->
message = data.message || data.msg
if message
# TODO: remove 'Open Assessment' specific default title
title = data.title || gettext("OpenAssessment Save Error")
@alert = new NotificationView.Error
title: title
message: message
closeIcon: false
shown: false
@alert.show()
@savingNotification.hide()
_handleSave: (data) ->
# Starting to save, so show a notification
if data.state == 'start'
message = data.message || gettext('Saving&hellip;')
@notification = new NotificationView.Mini
title: message
@notification.show()
else if name == 'edit-modal-shown'
@modal = data
# Finished saving, so hide the notification and refresh appropriately
else if data.state == 'end'
@_hideAlerts()
else if name == 'edit-modal-hidden'
@modal = null
# Notify the modal that the save has completed so that it can hide itself
# and then refresh the xblock.
if @modal and @modal.onSave
@modal.onSave()
# ... else ask it to refresh the newly saved xblock
else if data.element
@refreshXBlock(data.element)
else if name == 'cancel'
@_hideAlerts()
if @modal
@modal.cancel()
@notification.hide()
else if name == 'error'
if 'msg' of data
@alert.options.message = data.msg
@alert.show()
_handleCancel: () ->
@_hideAlerts()
if @modal
@modal.cancel()
@notify('modal-hidden')
_hideAlerts: () ->
# Hide any alerts that are being shown
if @alert.options.shown
if @alert && @alert.options.shown
@alert.hide()
@PreviewRuntime = {}
class PreviewRuntime.v1 extends BaseRuntime.v1
handlerPrefix: '/preview/xblock'
@StudioRuntime = {}
class StudioRuntime.v1 extends BaseRuntime.v1
handlerPrefix: '/xblock'
define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
"js/views/feedback_prompt", "js/views/pages/container", "js/models/xblock_info"],
function ($, _, create_sinon, edit_helpers, Prompt, ContainerPage, XBlockInfo) {
function ($, _, str, create_sinon, edit_helpers, Prompt, ContainerPage, XBlockInfo) {
describe("ContainerPage", function() {
var lastRequest, renderContainerPage, expectComponents, respondWithHtml,
......@@ -96,7 +96,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
editButtons.first().click();
// Expect a request to be made to show the studio view for the container
expect(lastRequest().url.startsWith('/xblock/locator-container/studio_view')).toBeTruthy();
expect(str.startsWith(lastRequest().url, '/xblock/locator-container/studio_view')).toBeTruthy();
create_sinon.respondWithJson(requests, {
html: mockContainerXBlockHtml,
resources: []
......@@ -112,7 +112,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
expect(edit_helpers.isShowingModal()).toBeFalsy();
// Expect the last request be to refresh the container page
expect(lastRequest().url.startsWith('/xblock/locator-container/container_preview')).toBeTruthy();
expect(str.startsWith(lastRequest().url, '/xblock/locator-container/container_preview')).toBeTruthy();
create_sinon.respondWithJson(requests, {
html: mockUpdatedContainerXBlockHtml,
resources: []
......@@ -149,7 +149,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
expect(editButtons.length).toBe(6);
editButtons[0].click();
// Make sure that the correct xblock is requested to be edited
expect(lastRequest().url.startsWith('/xblock/locator-component-A1/studio_view')).toBeTruthy();
expect(str.startsWith(lastRequest().url, '/xblock/locator-component-A1/studio_view')).toBeTruthy();
create_sinon.respondWithJson(requests, {
html: mockXBlockEditorHtml,
resources: []
......
define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/module_info",
define(["jquery", "underscore.string", "jasmine", "coffee/src/views/unit", "js/models/module_info",
"js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", "jasmine-stealth"],
function ($, _, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) {
function ($, str, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) {
var requests, unitView, initialize, lastRequest, respondWithHtml, verifyComponents, i,
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
......@@ -150,7 +150,7 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m
});
describe("Disabled edit/publish links during ajax call", function() {
var link, i,
var link,
draft_states = [
{
state: "draft",
......@@ -204,7 +204,7 @@ define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/m
expect(editButtons.length).toBe(2);
editButtons[1].click();
// Make sure that the correct xblock is requested to be edited
expect(lastRequest().url.startsWith('/xblock/loc_2/studio_view')).toBeTruthy();
expect(str.startsWith(lastRequest().url, '/xblock/loc_2/studio_view')).toBeTruthy();
create_sinon.respondWithJson(requests, {
html: mockXBlockEditorHtml,
resources: []
......
define(["js/spec_helpers/edit_helpers", "js/views/modals/base_modal", "xblock/cms.runtime.v1"],
function (edit_helpers, BaseModal) {
describe("Studio Runtime v1", function() {
var runtime;
beforeEach(function () {
edit_helpers.installEditTemplates();
runtime = new window.StudioRuntime.v1();
});
it('allows events to be listened to', function() {
var canceled = false;
runtime.listenTo('cancel', function() {
canceled = true;
});
expect(canceled).toBeFalsy();
runtime.notify('cancel', {});
expect(canceled).toBeTruthy();
});
it('shows save notifications', function() {
var title = "Mock saving...",
notificationSpy = edit_helpers.createNotificationSpy();
runtime.notify('save', {
state: 'start',
message: title
});
edit_helpers.verifyNotificationShowing(notificationSpy, title);
runtime.notify('save', {
state: 'end'
});
edit_helpers.verifyNotificationHidden(notificationSpy);
});
it('shows error messages', function() {
var title = "Mock Error",
message = "This is a mock error.",
notificationSpy = edit_helpers.createNotificationSpy("Error");
runtime.notify('error', {
title: title,
message: message
});
edit_helpers.verifyNotificationShowing(notificationSpy, title);
});
describe("Modal Dialogs", function() {
var MockModal, modal, showMockModal;
MockModal = BaseModal.extend({
getContentHtml: function() {
return readFixtures('mock/mock-modal.underscore');
}
});
showMockModal = function() {
modal = new MockModal({
title: "Mock Modal"
});
modal.show();
};
beforeEach(function () {
edit_helpers.installEditTemplates();
});
afterEach(function() {
edit_helpers.hideModalIfShowing(modal);
});
it('cancels a modal dialog', function () {
showMockModal();
runtime.notify('modal-shown', modal);
expect(edit_helpers.isShowingModal(modal)).toBeTruthy();
runtime.notify('cancel');
expect(edit_helpers.isShowingModal(modal)).toBeFalsy();
});
});
});
});
......@@ -21,8 +21,8 @@ define(["jquery", "js/views/feedback_notification", "js/spec_helpers/create_sino
appendSetFixtures('<div id="page-notification"></div>');
};
createNotificationSpy = function() {
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]);
createNotificationSpy = function(type) {
var notificationSpy = spyOnConstructor(NotificationView, type || "Mini", ["show", "hide"]);
notificationSpy.show.andReturn(notificationSpy);
return notificationSpy;
};
......
......@@ -71,7 +71,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
// Notify the runtime that the modal has been shown
if (runtime) {
this.runtime = runtime;
runtime.notify("edit-modal-shown", this);
runtime.notify('modal-shown', this);
}
// Update the modal's header
......@@ -166,7 +166,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
// Notify the runtime that the modal has been hidden
if (this.runtime) {
this.runtime.notify('edit-modal-hidden');
this.runtime.notify('modal-hidden');
}
// Completely clear the contents of the modal
......@@ -180,7 +180,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
displayName;
if (xblockWrapperElement.length > 0) {
xblockElement = xblockWrapperElement.find('.xblock');
displayName = xblockWrapperElement.find('.xblock-header .header-details').text().trim();
displayName = xblockWrapperElement.find('.xblock-header .header-details .xblock-display-name').text().trim();
// If not found, try looking for the old unit page style rendering
if (!displayName) {
displayName = this.xblockElement.find('.component-header').text().trim();
......
......@@ -2,11 +2,9 @@
* 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/feedback_notification",
"js/views/baseview", "js/views/container", "js/views/xblock", "js/views/components/add_xblock",
"js/views/modals/edit_xblock", "js/models/xblock_info"],
function ($, _, gettext, NotificationView, BaseView, ContainerView, XBlockView, AddXBlockComponent,
EditXBlockModal, XBlockInfo) {
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/container",
"js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"],
function ($, _, gettext, BaseView, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, XBlockInfo) {
var XBlockContainerPage = BaseView.extend({
// takes XBlockInfo as a model
......@@ -36,6 +34,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
// Render the xblock
xblockView.render({
success: function(xblock) {
xblockView.xblock.runtime.notify("page-shown", self);
xblockView.$el.removeClass('is-hidden');
self.renderAddXBlockComponents();
self.onXBlockRefresh(xblockView);
......@@ -55,7 +54,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
},
refreshTitle: function() {
var title = this.$('.xblock-header .header-details span').first().text().trim();
var title = this.$('.xblock-header .header-details .xblock-display-name').first().text().trim();
this.$('.page-header-title').text(title);
this.$('.page-header .subtitle a').last().text(title);
},
......@@ -112,12 +111,16 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
buttonPanel = target.closest('.add-xblock-component'),
listPanel = buttonPanel.prev(),
scrollOffset = this.getScrollOffset(buttonPanel),
placeholderElement = $('<div></div>').appendTo(listPanel),
placeholderElement = $('<div class="studio-xblock-wrapper"></div>').appendTo(listPanel),
requestData = _.extend(template, {
parent_locator: parentLocator
});
return $.postJSON(this.getURLRoot() + '/', requestData,
_.bind(this.onNewXBlock, this, placeholderElement, scrollOffset));
_.bind(this.onNewXBlock, this, placeholderElement, scrollOffset))
.fail(function() {
// Remove the placeholder if the update failed
placeholderElement.remove();
});
},
duplicateComponent: function(xblockElement) {
......@@ -129,14 +132,18 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
this.runOperationShowingMessage(gettext('Duplicating&hellip;'),
function() {
var scrollOffset = self.getScrollOffset(xblockElement),
placeholderElement = $('<div></div>').insertAfter(xblockElement),
placeholderElement = $('<div class="studio-xblock-wrapper"></div>').insertAfter(xblockElement),
parentElement = self.findXBlockElement(parent),
requestData = {
duplicate_source_locator: xblockElement.data('locator'),
parent_locator: parentElement.data('locator')
};
return $.postJSON(self.getURLRoot() + '/', requestData,
_.bind(self.onNewXBlock, self, placeholderElement, scrollOffset));
_.bind(self.onNewXBlock, self, placeholderElement, scrollOffset))
.fail(function() {
// Remove the placeholder if the update failed
placeholderElement.remove();
});
});
},
......@@ -153,16 +160,21 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
url: self.getURLRoot() + "/" +
xblockElement.data('locator') + "?" +
$.param({recurse: true, all_versions: false})
}).success(function() {
// get the parent so we can remove this component from its parent.
var parent = self.findXBlockElement(xblockElement.parent());
xblockElement.remove();
self.xblockView.updateChildren(parent);
});
}).success(_.bind(self.onDelete, self, xblockElement));
});
});
},
onDelete: function(xblockElement) {
// get the parent so we can remove this component from its parent.
var xblockView = this.xblockView,
xblock = xblockView.xblock,
parent = this.findXBlockElement(xblockElement.parent());
xblockElement.remove();
xblockView.updateChildren(parent);
xblock.runtime.notify('deleted-child', parent.data('locator'));
},
onNewXBlock: function(xblockElement, scrollOffset, data) {
this.setScrollOffset(xblockElement, scrollOffset);
xblockElement.data('locator', data.locator);
......@@ -173,10 +185,11 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
* Refreshes the specified xblock's display. If the xblock is an inline child of a
* reorderable container then the element will be refreshed inline. If not, then the
* parent container will be refreshed instead.
* @param xblockElement The element representing the xblock to be refreshed.
* @param element An element representing the xblock to be refreshed.
*/
refreshXBlock: function(xblockElement) {
var parentElement = xblockElement.parent(),
refreshXBlock: function(element) {
var xblockElement = this.findXBlockElement(element),
parentElement = xblockElement.parent(),
rootLocator = this.xblockView.model.id;
if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) {
this.render({ });
......
......@@ -233,7 +233,7 @@
@include transition(all $tmg-f3 linear 0s);
display: block;
border-radius: 3px;
padding: ($baseline/4) ($baseline/2);
padding: 3px ($baseline/2);
color: $gray-l1;
&:hover {
......
......@@ -28,6 +28,11 @@
display: inline-block;
width: 50%;
vertical-align: middle;
.xblock-display-name {
display: inline-block;
vertical-align: middle;
}
}
.header-actions {
......
......@@ -253,6 +253,46 @@ body.view-container .content-primary {
}
}
// groups in experiments
.wrapper-groups {
.title {
@extend %t-title7;
margin-left: ($baseline/2);
color: $gray-l1;
}
&.is-active {
// Don't show delete buttons on active groups
.wrapper-xblock.level-nesting > .xblock-header .action-delete {
display: none;
}
}
&.is-inactive {
margin: $baseline 0 0 0;
border-top: 2px dotted $gray-l2;
padding: ($baseline/2) 0;
background-color: $gray-l4;
.wrapper-xblock.level-nesting {
@include transition(all $tmg-f2 linear 0s);
opacity: .7;
&:hover {
opacity: 1;
}
}
.new-component-item {
display: none;
}
}
}
// add a new component menu override - most styles currently live in _unit.scss
.new-component-item {
margin: $baseline ($baseline/2);
......
<header class="xblock-header">
<div class="xblock-header-primary">
<div class="header-details">
<span>Test Container</span>
<span class="xblock-display-name">Test Container</span>
</div>
<div class="header-actions">
<ul class="actions-list">
......@@ -22,7 +22,7 @@
<i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">Expand or Collapse</span>
</a>
<span>Group A</span>
<span class="xblock-display-name">Group A</span>
</div>
<div class="header-actions">
<ul class="actions-list">
......@@ -131,7 +131,7 @@
<i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">Expand or Collapse</span>
</a>
<span>Group B</span>
<span class="xblock-display-name">Group B</span>
</div>
<div class="header-actions">
<ul class="actions-list">
......
......@@ -4,7 +4,7 @@
<i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">Expand or Collapse</span>
</a>
<span>Empty Vertical Test</span>
<span class="xblock-display-name">Empty Vertical Test</span>
</div>
<div class="header-actions">
<ul class="actions-list">
......
<header class="xblock-header">
<div class="xblock-header-primary">
<div class="header-details">
<span>Updated Test Container</span>
<span class="xblock-display-name">Updated Test Container</span>
</div>
<div class="header-actions">
<ul class="actions-list">
......
<li class="studio-xblock-wrapper is-draggable">
<header class="xblock-header">
<div class="header-details">
<span>Mock XBlock</span>
<span class="xblock-display-name">Mock XBlock</span>
</div>
<div class="header-actions">
<ul class="actions-list">
......
<li class="studio-xblock-wrapper is-draggable">
<header class="xblock-header">
<div class="header-details">
<span>Mock XBlock</span>
<span class="xblock-display-name">Mock XBlock</span>
</div>
<div class="header-actions">
<ul class="actions-list">
......
......@@ -8,7 +8,7 @@
<i class="icon-caret-down ui-toggle-expansion"></i>
<span class="sr">${_('Expand or Collapse')}</span>
</a>
<span>${xblock.display_name_with_default | h}</span>
<span class="xblock-display-name">${xblock.display_name_with_default | h}</span>
</div>
<div class="header-actions">
<ul class="actions-list">
......
......@@ -20,7 +20,7 @@ collapsible_class = "is-collapsible" if xblock.has_children else ""
<section class="wrapper-xblock ${section_class} ${collapsible_class}" data-course-key="${xblock.location.course_key}">
% endif
<header class="xblock-header">
<header class="xblock-header xblock-header-${xblock.category}">
<div class="xblock-header-primary">
<div class="header-details">
% if show_inline:
......@@ -29,7 +29,7 @@ collapsible_class = "is-collapsible" if xblock.has_children else ""
<span class="sr">${_('Expand or Collapse')}</span>
</a>
% endif
<span>${xblock.display_name_with_default | h}</span>
<span class="xblock-display-name">${xblock.display_name_with_default | h}</span>
</div>
<div class="header-actions">
<ul class="actions-list">
......@@ -47,13 +47,13 @@ collapsible_class = "is-collapsible" if xblock.has_children else ""
<span class="sr">${_("Duplicate")}</span>
</a>
</li>
<li class="action-item action-delete">
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
<i class="icon-trash"></i>
<span class="sr">${_("Delete")}</span>
</a>
</li>
% endif
<li class="action-item action-delete">
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
<i class="icon-trash"></i>
<span class="sr">${_("Delete")}</span>
</a>
</li>
% if is_reorderable:
<li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
......
<%! from django.utils.translation import ugettext as _ %>
<%include file="metadata-edit.html" />
% if disable_user_partition_editing:
<div class="message setting-message">
% if not selected_partition:
<p>${_("This content experiment refers to a group configuration that has been deleted.")}</p>
% else:
<p>${_("This content experiment uses group configuration '{0}'.".format("<strong>"+str(selected_partition.name)+"</strong>"))}</p>
% endif
<p class="tip setting-help">${_("After you select the group configuration and save the content experiment, you cannot change this setting.")}</p>
</div>
% endif
......@@ -16,7 +16,6 @@ from xblock.fragment import Fragment
from xmodule.seq_module import SequenceModule
from xmodule.vertical_module import VerticalModule
from xmodule.x_module import shim_xmodule_js, XModuleDescriptor, XModule
from lms.lib.xblock.runtime import quote_slashes
from xmodule.modulestore import MONGO_MODULESTORE_TYPE
from xmodule.modulestore.django import modulestore
......@@ -60,7 +59,7 @@ def wrap_xblock(runtime_class, block, view, frag, context, usage_id_serializer,
css_classes = ['xblock', 'xblock-' + view]
if isinstance(block, (XModule, XModuleDescriptor)):
if view == 'student_view':
if view in ['student_view', 'author_view']:
# The block is acting as an XModule
css_classes.append('xmodule_display')
elif view == 'studio_view':
......
.setting-message {
margin: ($baseline/2) $baseline;
border-top: 3px solid $gray-l2;
background-color: $gray-l5;
padding: $baseline;
}
.setting-help {
@include font-size(12);
font-color: $gray-l6;
}
/* JavaScript for editing operations that can be done on the split test author view. */
window.SplitTestAuthorView = function (runtime, element) {
var $element = $(element);
$element.find('.add-missing-groups-button').click(function () {
runtime.notify('save', {
state: 'start',
element: element,
message: gettext('Creating missing groups&hellip;')
});
$.post(runtime.handlerUrl(element, 'add_missing_groups')).done(function() {
runtime.notify('save', {
state: 'end',
element: element
});
});
});
// Listen to delete events so that the view can refresh when the last inactive group is removed.
runtime.listenTo('deleted-child', function(parentLocator) {
var splitTestLocator = $element.closest('.studio-xblock-wrapper').data('locator'),
inactiveGroups = $element.find('.is-inactive .studio-xblock-wrapper');
if (splitTestLocator === parentLocator && inactiveGroups.length === 0) {
runtime.refreshXBlock($element);
}
});
return {};
};
"""
Mixin to support editing in Studio.
"""
from xmodule.x_module import module_attr
class StudioEditableModule(object):
"""
Helper methods for supporting Studio editing of xblocks/xmodules.
Helper methods for supporting Studio editing of xmodules.
This class is only intended to be used with an XModule, as it assumes the existence of
self.descriptor and self.system.
"""
def render_children(self, context, fragment, can_reorder=False, can_add=False, view_name='student_view'):
def render_children(self, context, fragment, can_reorder=False, can_add=False):
"""
Renders the children of the module with HTML appropriate for Studio. If can_reorder is True,
then the children will be rendered to support drag and drop.
......@@ -22,7 +23,7 @@ class StudioEditableModule(object):
if can_reorder:
context['reorderable_items'].add(child.location)
child_module = self.system.get_module(child) # pylint: disable=E1101
rendered_child = child_module.render(view_name, context)
rendered_child = child_module.render(StudioEditableModule.get_preview_view_name(child_module), context)
fragment.add_frag_resources(rendered_child)
contents.append({
......@@ -36,3 +37,21 @@ class StudioEditableModule(object):
'can_add': can_add,
'can_reorder': can_reorder,
}))
@staticmethod
def get_preview_view_name(block):
"""
Helper method for getting preview view name (student_view or author_view) for a given module.
"""
return 'author_view' if hasattr(block, 'author_view') else 'student_view'
class StudioEditableDescriptor(object):
"""
Helper mixin for supporting Studio editing of xmodules.
This class is only intended to be used with an XModule Descriptor. This class assumes that the associated
XModule will have an "author_view" method for returning an editable preview view of the module.
"""
author_view = module_attr("author_view")
has_author_view = True
......@@ -12,7 +12,6 @@ class StudioEditableModuleTestCase(BaseVerticalModuleTest):
"""
reorderable_items = set()
context = {
'runtime_type': 'studio',
'container_view': True,
'reorderable_items': reorderable_items,
'read_only': False,
......@@ -20,6 +19,6 @@ class StudioEditableModuleTestCase(BaseVerticalModuleTest):
}
# Both children of the vertical should be rendered as reorderable
self.module_system.render(self.vertical, 'student_view', context).content
self.module_system.render(self.vertical, 'author_view', context).content # pylint: disable=expression-not-assigned
self.assertIn(self.vertical.get_children()[0].location, reorderable_items)
self.assertIn(self.vertical.get_children()[1].location, reorderable_items)
......@@ -52,24 +52,22 @@ class VerticalModuleTestCase(BaseVerticalModuleTest):
def test_render_studio_view(self):
"""
Test the rendering of the Studio view
Test the rendering of the Studio author view
"""
# Vertical shouldn't render children on the unit page
context = {
'runtime_type': 'studio',
'container_view': False,
}
html = self.module_system.render(self.vertical, 'student_view', context).content
html = self.module_system.render(self.vertical, 'author_view', context).content
self.assertNotIn(self.test_html_1, html)
self.assertNotIn(self.test_html_2, html)
# Vertical should render reorderable children on the container page
reorderable_items = set()
context = {
'runtime_type': 'studio',
'container_view': True,
'reorderable_items': reorderable_items,
}
html = self.module_system.render(self.vertical, 'student_view', context).content
html = self.module_system.render(self.vertical, 'author_view', context).content
self.assertIn(self.test_html_1, html)
self.assertIn(self.test_html_2, html)
......@@ -2,7 +2,7 @@ from xblock.fragment import Fragment
from xmodule.x_module import XModule
from xmodule.seq_module import SequenceDescriptor
from xmodule.progress import Progress
from xmodule.studio_editable import StudioEditableModule
from xmodule.studio_editable import StudioEditableModule, StudioEditableDescriptor
from pkg_resources import resource_string
from copy import copy
......@@ -19,27 +19,6 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule):
''' Layout module for laying out submodules vertically.'''
def student_view(self, context):
# When rendering a Studio preview, use a different template to support drag and drop.
if context and context.get('runtime_type', None) == 'studio':
return self.studio_preview_view(context)
return self.render_view(context, 'vert_module.html')
def studio_preview_view(self, context):
"""
Renders the Studio preview view, which supports drag and drop.
"""
fragment = Fragment()
# For the container page we want the full drag-and-drop, but for unit pages we want
# a more concise version that appears alongside the "View =>" link.
if context.get('container_view'):
self.render_children(context, fragment, can_reorder=True, can_add=True)
return fragment
def render_view(self, context, template_name):
"""
Helper method for rendering student_view and the Studio version.
"""
fragment = Fragment()
contents = []
......@@ -55,12 +34,23 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule):
'content': rendered_child.content
})
fragment.add_content(self.system.render_template(template_name, {
fragment.add_content(self.system.render_template('vert_module.html', {
'items': contents,
'xblock_context': context,
}))
return fragment
def author_view(self, context):
"""
Renders the Studio preview view, which supports drag and drop.
"""
fragment = Fragment()
# For the container page we want the full drag-and-drop, but for unit pages we want
# a more concise version that appears alongside the "View =>" link.
if context.get('container_view'):
self.render_children(context, fragment, can_reorder=True, can_add=True)
return fragment
def get_progress(self):
# TODO: Cache progress or children array?
children = self.get_children()
......@@ -77,7 +67,10 @@ class VerticalModule(VerticalFields, XModule, StudioEditableModule):
return new_class
class VerticalDescriptor(VerticalFields, SequenceDescriptor):
class VerticalDescriptor(VerticalFields, SequenceDescriptor, StudioEditableDescriptor):
"""
Descriptor class for editing verticals.
"""
module_class = VerticalModule
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
......
......@@ -1138,7 +1138,7 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p
return result
def render(self, block, view_name, context=None):
if view_name == 'student_view':
if view_name in ['student_view', 'author_view']:
assert block.xmodule_runtime is not None
if isinstance(block, (XModule, XModuleDescriptor)):
to_render = block._xmodule
......
......@@ -200,6 +200,7 @@ class CourseFixture(StudioApiFixture):
self._handouts = []
self._children = []
self._assets = []
self._advanced_settings = {}
def __str__(self):
"""
......@@ -236,6 +237,12 @@ class CourseFixture(StudioApiFixture):
"""
self._assets.extend(asset_name)
def add_advanced_settings(self, settings):
"""
Adds advanced settings to be set on the course when the install method is called.
"""
self._advanced_settings.update(settings)
def install(self):
"""
Create the course and XBlocks within the course.
......@@ -248,6 +255,7 @@ class CourseFixture(StudioApiFixture):
self._install_course_handouts()
self._configure_course()
self._upload_assets()
self._add_advanced_settings()
self._create_xblock_children(self._course_location, self._children)
return self
......@@ -415,6 +423,23 @@ class CourseFixture(StudioApiFixture):
raise CourseFixtureError('Could not upload {asset_name} with {url}. Status code: {code}'.format(
asset_name=asset_name, url=url, code=upload_response.status_code))
def _add_advanced_settings(self):
"""
Add advanced settings.
"""
url = STUDIO_BASE_URL + "/settings/advanced/" + self._course_key
# POST advanced settings to Studio
response = self.session.post(
url, data=self._encode_post_dict(self._advanced_settings),
headers=self.headers,
)
if not response.ok:
raise CourseFixtureError(
"Could not update advanced details to '{0}' with {1}: Status was {2}.".format(
self._advanced_settings, url, response.status_code))
def _create_xblock_children(self, parent_loc, xblock_descriptions):
"""
Recursively create XBlock children.
......@@ -489,6 +514,6 @@ class CourseFixture(StudioApiFixture):
Encode `post_dict` (a dictionary) as UTF-8 encoded JSON.
"""
return json.dumps({
k: v.encode('utf-8') if v is not None else v
k: v.encode('utf-8') if isinstance(v, basestring) else v
for k, v in post_dict.items()
})
......@@ -2,6 +2,7 @@ from bok_choy.page_object import PageObject
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.action_chains import ActionChains
from utils import click_css
from selenium.webdriver.support.ui import Select
class ComponentEditorView(PageObject):
......@@ -40,7 +41,7 @@ class ComponentEditorView(PageObject):
"""
return None
def get_setting_entry_index(self, label):
def get_setting_element(self, label):
"""
Returns the index of the setting entry with given label (display name) within the Settings modal.
"""
......@@ -48,15 +49,14 @@ class ComponentEditorView(PageObject):
setting_labels = self.q(css=self._bounded_selector('.metadata_edit .wrapper-comp-setting .setting-label'))
for index, setting in enumerate(setting_labels):
if setting.text == label:
return index
return self.q(css=self._bounded_selector('.metadata_edit div.wrapper-comp-setting .setting-input'))[index]
return None
def set_field_value_and_save(self, label, value):
"""
Set the field with given label (display name) to the specified value, and presses Save.
Sets the text field with given label (display name) to the specified value, and presses Save.
"""
index = self.get_setting_entry_index(label)
elem = self.q(css=self._bounded_selector('.metadata_edit div.wrapper-comp-setting input.setting-input'))[index]
elem = self.get_setting_element(label)
# Click in the field, delete the value there.
action = ActionChains(self.browser).click(elem)
for _x in range(0, len(elem.get_attribute('value'))):
......@@ -64,3 +64,12 @@ class ComponentEditorView(PageObject):
# Send the new text, then Tab to move to the next field (so change event is triggered).
action.send_keys(value).send_keys(Keys.TAB).perform()
click_css(self, 'a.action-save')
def set_select_value_and_save(self, label, value):
"""
Sets the select with given label (display name) to the specified value, and presses Save.
"""
elem = self.get_setting_element(label)
select = Select(elem)
select.select_by_value(value)
click_css(self, 'a.action-save')
......@@ -54,7 +54,24 @@ class ContainerPage(PageObject):
"""
Return a list of xblocks loaded on the container page.
"""
return self.q(css=XBlockWrapper.BODY_SELECTOR).map(
return self._get_xblocks()
@property
def inactive_xblocks(self):
"""
Return a list of inactive xblocks loaded on the container page.
"""
return self._get_xblocks(".is-inactive ")
@property
def active_xblocks(self):
"""
Return a list of active xblocks loaded on the container page.
"""
return self._get_xblocks(".is-active ")
def _get_xblocks(self, prefix=""):
return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map(
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
def drag(self, source_index, target_index):
......@@ -77,15 +94,6 @@ class ContainerPage(PageObject):
).release().perform()
wait_for_notification(self)
def add_discussion(self, menu_index):
"""
Add a new instance of the discussion category.
menu_index specifies which instance of the menus should be used (based on vertical
placement within the page).
"""
click_css(self, 'a>span.large-discussion-icon', menu_index)
def duplicate(self, source_index):
"""
Duplicate the item with index source_index (based on vertical placement in page).
......@@ -101,6 +109,11 @@ class ContainerPage(PageObject):
click_css(self, 'a.button.action-primary', 0)
def edit(self):
"""
Clicks the "edit" button for the first component on the page.
Same as the implementation in unit.py, unit and component pages will be merging.
"""
self.q(css='.edit-button').first.click()
EmptyPromise(
lambda: self.q(css='.xblock-studio_view').present,
......@@ -109,6 +122,18 @@ class ContainerPage(PageObject):
return self
def add_missing_groups(self):
"""
Click the "add missing groups" link.
"""
click_css(self, '.add-missing-groups-button')
def missing_groups_button_present(self):
"""
Returns True if the "add missing groups" button is present.
"""
return self.q(css='.add-missing-groups-button').present
class XBlockWrapper(PageObject):
"""
......@@ -161,4 +186,4 @@ class XBlockWrapper(PageObject):
@property
def preview_selector(self):
return self._bounded_selector('.xblock-student_view')
return self._bounded_selector('.xblock-student_view,.xblock-author_view')
......@@ -27,7 +27,7 @@ class UnitPage(PageObject):
def _is_finished_loading():
# Wait until all components have been loaded
number_of_leaf_xblocks = len(self.q(css='{} .xblock-student_view'.format(Component.BODY_SELECTOR)).results)
number_of_leaf_xblocks = len(self.q(css='{} .xblock-author_view,.xblock-student_view'.format(Component.BODY_SELECTOR)).results)
is_done = len(self.q(css=Component.BODY_SELECTOR).results) == number_of_leaf_xblocks
return (is_done, is_done)
......@@ -99,9 +99,14 @@ class Component(PageObject):
@property
def preview_selector(self):
return self._bounded_selector('.xblock-student_view')
return self._bounded_selector('.xblock-author_view,.xblock-student_view')
def edit(self):
"""
Clicks the "edit" button for the first component on the page.
Same as the implementation in unit.py, unit and component pages will be merging.
"""
self.q(css=self._bounded_selector('.edit-button')).first.click()
EmptyPromise(
lambda: self.q(css='.xblock-studio_view').present,
......
......@@ -9,10 +9,12 @@ def click_css(page, css, source_index=0, require_notification=True):
"""
Click the button/link with the given css and index on the specified page (subclass of PageObject).
Will only consider buttons with a non-zero size.
If require_notification is False (default value is True), the method will return immediately.
Otherwise, it will wait for the "mini-notification" to appear and disappear.
"""
buttons = page.q(css=css)
buttons = page.q(css=css).filter(lambda el: el.size['width'] > 0)
target = buttons[source_index]
ActionChains(page.browser).click(target).release().perform()
if require_notification:
......@@ -31,5 +33,36 @@ def wait_for_notification(page):
num_notifications = len(page.q(css='.wrapper-notification-mini.is-hiding'))
return (num_notifications == 1, num_notifications)
Promise(_is_saving, 'Notification showing.').fulfill()
Promise(_is_saving_done, 'Notification hidden.').fulfill()
Promise(_is_saving, 'Notification should have been shown.').fulfill()
Promise(_is_saving_done, 'Notification should have been hidden.').fulfill()
def add_discussion(page, menu_index):
"""
Add a new instance of the discussion category.
menu_index specifies which instance of the menus should be used (based on vertical
placement within the page).
"""
click_css(page, 'a>span.large-discussion-icon', menu_index)
def add_advanced_component(page, menu_index, name):
"""
Adds an instance of the advanced component with the specified name.
menu_index specifies which instance of the menus should be used (based on vertical
placement within the page).
"""
click_css(page, 'a>span.large-advanced-icon', menu_index, require_notification=False)
# Sporadically, the advanced component was not getting created after the click_css call on the category (below).
# Try making sure that the menu of advanced components is visible before clicking (the HTML is always on the
# page, but will have display none until the large-advanced-icon is clicked).
def is_advanced_components_showing():
advanced_buttons = page.q(css=".new-component-advanced").filter(lambda el: el.size['width'] > 0)
return (len(advanced_buttons) == 1, len(advanced_buttons))
Promise(is_advanced_components_showing, "Advanced component menu not showing").fulfill()
click_css(page, 'a[data-category={}]'.format(name))
......@@ -8,6 +8,7 @@ from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from .helpers import UniqueCourseTest
from ..pages.studio.component_editor import ComponentEditorView
from ..pages.studio.utils import add_discussion
from unittest import skip
......@@ -32,6 +33,87 @@ class ContainerBase(UniqueCourseTest):
self.course_info['run']
)
self.setup_fixtures()
self.auth_page = AutoAuthPage(
self.browser,
staff=False,
username=self.user.get('username'),
email=self.user.get('email'),
password=self.user.get('password')
)
self.auth_page.visit()
def setup_fixtures(self):
pass
def go_to_container_page(self, make_draft=False):
"""
Go to the test container page.
If make_draft is true, the unit page (accessed on way to container page) will be put into draft mode.
"""
unit = self.go_to_unit_page(make_draft)
container = unit.components[0].go_to_container()
return container
def go_to_unit_page(self, make_draft=False):
"""
Go to the test unit page.
If make_draft is true, the unit page will be put into draft mode.
"""
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
if make_draft:
unit.edit_draft()
return unit
def verify_ordering(self, container, expected_orderings):
"""
Verifies the expected ordering of xblocks on the page.
"""
xblocks = container.xblocks
blocks_checked = set()
for expected_ordering in expected_orderings:
for xblock in xblocks:
parent = expected_ordering.keys()[0]
if xblock.name == parent:
blocks_checked.add(parent)
children = xblock.children
expected_length = len(expected_ordering.get(parent))
self.assertEqual(
expected_length, len(children),
"Number of children incorrect for group {0}. Expected {1} but got {2}.".format(parent, expected_length, len(children)))
for idx, expected in enumerate(expected_ordering.get(parent)):
self.assertEqual(expected, children[idx].name)
blocks_checked.add(expected)
break
self.assertEqual(len(blocks_checked), len(xblocks))
def do_action_and_verify(self, action, expected_ordering):
"""
Perform the supplied action and then verify the resulting ordering.
"""
container = self.go_to_container_page(make_draft=True)
action(container)
self.verify_ordering(container, expected_ordering)
# Reload the page to see that the change was persisted.
container = self.go_to_container_page()
self.verify_ordering(container, expected_ordering)
class NestedVerticalTest(ContainerBase):
__test__ = False
"""
Sets up a course structure with nested verticals.
"""
def setup_fixtures(self):
self.container_title = ""
self.group_a = "Expand or Collapse\nGroup A"
self.group_b = "Expand or Collapse\nGroup B"
......@@ -55,18 +137,6 @@ class ContainerBase(UniqueCourseTest):
self.duplicate_label = "Duplicate of '{0}'"
self.discussion_label = "Discussion"
self.setup_fixtures()
self.auth_page = AutoAuthPage(
self.browser,
staff=False,
username=self.user.get('username'),
email=self.user.get('email'),
password=self.user.get('password')
)
self.auth_page.visit()
def setup_fixtures(self):
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
......@@ -96,46 +166,8 @@ class ContainerBase(UniqueCourseTest):
self.user = course_fix.user
def go_to_container_page(self, make_draft=False):
unit = self.go_to_unit_page(make_draft)
container = unit.components[0].go_to_container()
return container
def go_to_unit_page(self, make_draft=False):
self.outline.visit()
subsection = self.outline.section('Test Section').subsection('Test Subsection')
unit = subsection.toggle_expand().unit('Test Unit').go_to()
if make_draft:
unit.edit_draft()
return unit
def verify_ordering(self, container, expected_orderings):
xblocks = container.xblocks
for expected_ordering in expected_orderings:
for xblock in xblocks:
parent = expected_ordering.keys()[0]
if xblock.name == parent:
children = xblock.children
expected_length = len(expected_ordering.get(parent))
self.assertEqual(
expected_length, len(children),
"Number of children incorrect for group {0}. Expected {1} but got {2}.".format(parent, expected_length, len(children)))
for idx, expected in enumerate(expected_ordering.get(parent)):
self.assertEqual(expected, children[idx].name)
break
def do_action_and_verify(self, action, expected_ordering):
container = self.go_to_container_page(make_draft=True)
action(container)
self.verify_ordering(container, expected_ordering)
# Reload the page to see that the change was persisted.
container = self.go_to_container_page()
self.verify_ordering(container, expected_ordering)
class DragAndDropTest(ContainerBase):
class DragAndDropTest(NestedVerticalTest):
"""
Tests of reordering within the container page.
"""
......@@ -196,7 +228,7 @@ class DragAndDropTest(ContainerBase):
def add_new_components_and_rearrange(container):
# Add a video component to Group 1
container.add_discussion(group_a_menu)
add_discussion(container, group_a_menu)
# Duplicate the first item in Group A
container.duplicate(self.group_a_item_1_action_index)
......@@ -216,7 +248,7 @@ class DragAndDropTest(ContainerBase):
self.do_action_and_verify(add_new_components_and_rearrange, expected_ordering)
class AddComponentTest(ContainerBase):
class AddComponentTest(NestedVerticalTest):
"""
Tests of adding a component to the container page.
"""
......@@ -224,7 +256,7 @@ class AddComponentTest(ContainerBase):
def add_and_verify(self, menu_index, expected_ordering):
self.do_action_and_verify(
lambda (container): container.add_discussion(menu_index),
lambda (container): add_discussion(container, menu_index),
expected_ordering
)
......@@ -256,7 +288,7 @@ class AddComponentTest(ContainerBase):
self.add_and_verify(container_menu, expected_ordering)
class DuplicateComponentTest(ContainerBase):
class DuplicateComponentTest(NestedVerticalTest):
"""
Tests of duplicating a component on the container page.
"""
......@@ -302,7 +334,7 @@ class DuplicateComponentTest(ContainerBase):
self.do_action_and_verify(duplicate_twice, expected_ordering)
class DeleteComponentTest(ContainerBase):
class DeleteComponentTest(NestedVerticalTest):
"""
Tests of deleting a component from the container page.
"""
......@@ -319,10 +351,13 @@ class DeleteComponentTest(ContainerBase):
{self.group_a: [self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}]
self.delete_and_verify(self.group_a_item_1_action_index, expected_ordering)
# Group A itself has a delete icon now, so item_1 is index 1 instead of 0.
group_a_item_1_delete_index = 1
self.delete_and_verify(group_a_item_1_delete_index, expected_ordering)
class EditContainerTest(ContainerBase):
class EditContainerTest(NestedVerticalTest):
"""
Tests of editing a container.
"""
......
"""
Acceptance tests for Studio related to the split_test module.
"""
from ..fixtures.course import CourseFixture, XBlockFixtureDesc
from ..pages.studio.component_editor import ComponentEditorView
from test_studio_container import ContainerBase
from ..pages.studio.utils import add_advanced_component
from xmodule.partitions.partitions import Group, UserPartition
from bok_choy.promise import Promise
class SplitTest(ContainerBase):
"""
Tests for creating and editing split test instances in Studio.
"""
__test__ = True
def setup_fixtures(self):
course_fix = CourseFixture(
self.course_info['org'],
self.course_info['number'],
self.course_info['run'],
self.course_info['display_name']
)
course_fix.add_advanced_settings(
{
u"advanced_modules": ["split_test"],
u"user_partitions": [
UserPartition(0, 'Configuration alpha,beta', 'first', [Group("0", 'alpha'), Group("1", 'beta')]).to_json(),
UserPartition(1, 'Configuration 0,1,2', 'second', [Group("0", 'Group 0'), Group("1", 'Group 1'), Group("2", 'Group 2')]).to_json()
]
}
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection').add_children(
XBlockFixtureDesc('vertical', 'Test Unit')
)
)
).install()
self.course_fix = course_fix
self.user = course_fix.user
def verify_groups(self, container, active_groups, inactive_groups, verify_missing_groups_not_present=True):
"""
Check that the groups appear and are correctly categorized as to active and inactive.
Also checks that the "add missing groups" button/link is not present unless a value of False is passed
for verify_missing_groups_not_present.
"""
def wait_for_xblocks_to_render():
# First xblock is the container for the page, subtract 1.
return (len(active_groups) + len(inactive_groups) == len(container.xblocks) - 1, len(active_groups))
Promise(wait_for_xblocks_to_render, "Number of xblocks on the page are incorrect").fulfill()
def check_xblock_names(expected_groups, actual_blocks):
self.assertEqual(len(expected_groups), len(actual_blocks))
for idx, expected in enumerate(expected_groups):
self.assertEqual('Expand or Collapse\n{}'.format(expected), actual_blocks[idx].name)
check_xblock_names(active_groups, container.active_xblocks)
check_xblock_names(inactive_groups, container.inactive_xblocks)
# Verify inactive xblocks appear after active xblocks
check_xblock_names(active_groups + inactive_groups, container.xblocks[1:])
if verify_missing_groups_not_present:
self.verify_add_missing_groups_button_not_present(container)
def verify_add_missing_groups_button_not_present(self, container):
"""
Checks that the "add missing gorups" button/link is not present.
"""
def missing_groups_button_not_present():
button_present = container.missing_groups_button_present()
return (not button_present, not button_present)
Promise(missing_groups_button_not_present, "Add missing groups button should not be showing.").fulfill()
def create_poorly_configured_split_instance(self):
"""
Creates a split test instance with a missing group and an inactive group.
Returns the container page.
"""
unit = self.go_to_unit_page(make_draft=True)
add_advanced_component(unit, 0, 'split_test')
container = self.go_to_container_page()
container.edit()
component_editor = ComponentEditorView(self.browser, container.locator)
component_editor.set_select_value_and_save('Group Configuration', 'Configuration alpha,beta')
self.course_fix.add_advanced_settings(
{
u"user_partitions": [
UserPartition(0, 'Configuration alpha,beta', 'first',
[Group("0", 'alpha'), Group("2", 'gamma')]).to_json()
]
}
)
self.course_fix._add_advanced_settings()
return self.go_to_container_page()
def test_create_and_select_group_configuration(self):
"""
Tests creating a split test instance on the unit page, and then
assigning the group configuration.
"""
unit = self.go_to_unit_page(make_draft=True)
add_advanced_component(unit, 0, 'split_test')
container = self.go_to_container_page()
container.edit()
component_editor = ComponentEditorView(self.browser, container.locator)
component_editor.set_select_value_and_save('Group Configuration', 'Configuration alpha,beta')
self.verify_groups(container, ['alpha', 'beta'], [])
# Switch to the other group configuration. Must navigate again to the container page so
# that there is only a single "editor" on the page.
container = self.go_to_container_page()
container.edit()
component_editor = ComponentEditorView(self.browser, container.locator)
component_editor.set_select_value_and_save('Group Configuration', 'Configuration 0,1,2')
self.verify_groups(container, ['Group 0', 'Group 1', 'Group 2'], ['alpha', 'beta'])
# Reload the page to make sure the groups were persisted.
container = self.go_to_container_page()
self.verify_groups(container, ['Group 0', 'Group 1', 'Group 2'], ['alpha', 'beta'])
def test_missing_group(self):
"""
The case of a split test with invalid configuration (missing group).
"""
container = self.create_poorly_configured_split_instance()
container.add_missing_groups()
self.verify_groups(container, ['alpha', 'gamma'], ['beta'])
# Reload the page to make sure the groups were persisted.
container = self.go_to_container_page()
self.verify_groups(container, ['alpha', 'gamma'], ['beta'])
def test_delete_inactive_group(self):
"""
Test deleting an inactive group.
"""
container = self.create_poorly_configured_split_instance()
container.delete(0)
self.verify_groups(container, ['alpha'], [], verify_missing_groups_not_present=False)
......@@ -77,7 +77,11 @@ Studio
Class Features
~~~~~~~~~~~~~~
* studio_view (XBlock.view): The view used to render an editor in Studio.
* studio_view (XBlock.view): The view used to render an editor in Studio. The editor rendering can
be completely different from the LMS student_view, and it is only shown with the author selects "Edit".
* author_view (XBlock.view): An optional view of the xblock similar to student_view, but with possible inline
editing capabilities. This view differs from studio_view in that it should be as similar to student_view
as possible. When previewing xblocks within Studio, Studio will prefer author_view to student_view.
* non_editable_metadata_fields (property): A list of :class:`~xblock.fields.Field` objects that
shouldn't be displayed in the default editing view for Studio.
......
<%! from django.utils.translation import ugettext as _ %>
<%! from xmodule.split_test_module import ValidationMessageType %>
<%
split_test = context.get('split_test')
user_partition = split_test.descriptor.get_selected_partition()
messages = split_test.descriptor.validation_messages()
%>
% if is_root and not user_partition:
<div class="no-container-content">
% else:
<div class="wrapper-xblock-message">
% endif
% if not user_partition:
<div class="xblock-message error">
<p>
<i class="icon-exclamation-sign"></i>
${_("You must select a group configuration for this content experiment.")}
<a href="#" class="button edit-button action-button">
<i class="icon-pencil"></i> <span class="action-button-text">${_("Select a Group Configuration")}</span>
</a>
</p>
</div>
% else:
% if is_root or len(messages) == 0:
<div class="xblock-message information">
<p>
<span class="message-text">
${_("This content experiment uses group configuration '{experiment_name}'.").format(experiment_name=user_partition.name)}
</span>
</p>
</div>
% endif
% if is_root:
<ul>
% for message in messages:
<%
message_type = message.message_type
message_type_display_name = ValidationMessageType.display_name(message_type) if message_type else None
%>
<li class="xblock-message ${message_type}">
% if message_type == 'warning':
<i class="icon-warning-sign"></i>
% elif message_type == 'error':
<i class="icon-exclamation-sign"></i>
% endif
<span class="message-text">
% if message_type_display_name:
<span class="sr">${message_type_display_name}:</span>
% endif
${unicode(message)}
</span>
</li>
% endfor
</ul>
% elif len(messages) > 0:
<%
error_messages = (message for message in messages if message.message_type==ValidationMessageType.error)
%>
% if next(error_messages, False):
<div class="xblock-message error">
<i class="icon-exclamation-sign"></i>
${_("This content experiment has errors that should be resolved.")}
</div>
% else:
<div class="xblock-message error">
<i class="icon-warning-sign"></i>
${_("This content experiment has warnings that might need to be investigated.")}
</div>
% endif
% endif
% if is_missing_groups:
<a href="#" class="button add-missing-groups-button action-button">
<span class="action-button-text">${_("Create Missing Groups")}</span>
</a>
% endif
% endif
% if is_root and not user_partition:
</div>
% else:
</div>
% endif
% if is_root:
<div class="wrapper-groups is-active">
<h3 class="sr">${_("Active Groups")}</h3>
${active_groups_preview}
</div>
% if inactive_groups_preview:
<div class="wrapper-groups is-inactive">
<h3 class="title">${_("Inactive Groups")}</h3>
${inactive_groups_preview}
</div>
% endif
% endif
<%! from django.utils.translation import ugettext as _ %>
<%! from xmodule.split_test_module import ValidationMessageType %>
<%
split_test = context.get('split_test')
(message, message_type) = split_test.descriptor.validation_message()
message_type_display_name = ValidationMessageType.display_name(message_type) if message_type else None
is_configured = split_test.user_partition_id >= 0
%>
% if message or not is_configured:
% if is_root and not is_configured:
<div class="no-container-content">
% else:
<div class="wrapper-xblock-message">
<div class="xblock-message ${message_type}">
% endif
% if not is_configured:
<p><i class="icon-warning-sign"></i> ${_("You must select a group configuration for this content experiment.")}
<a href="#" class="button edit-button action-button">
<i class="icon-pencil"></i> <span class="action-button-text">${_("Select a Group Configuration")}</span>
</a>
</p>
% else:
<p>
% if message_type == 'warning':
<i class='icon-warning-sign'></i>
% elif message_type == 'error':
<i class='icon-exclamation-sign'></i>
% endif
<span class='message-text'>
% if message_type_display_name:
<span class='sr'>${message_type_display_name}:</span>
% endif
${message}
</span>
</p>
% endif
% if is_root and not is_configured:
</div>
% else:
</div>
</div>
% endif
% endif
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